From 9e5842a8c1b652d111465349b139c5c4c0a04a1a Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 12 Aug 2026 17:45:29 +0200 Subject: [PATCH 01/11] Added: the FamiTracker driver memory footprint --- docs/development/guidelines.md | 1 + docs/formats/famitracker.md | 47 +++++ .../formats/famitracker/footprint.py | 130 +++++++++++++ .../famitracker/specification/memory.py | 15 ++ .../formats/famitracker/test_footprint.py | 182 ++++++++++++++++++ 5 files changed, 375 insertions(+) create mode 100644 src/sampletones_core/formats/famitracker/footprint.py create mode 100644 src/sampletones_core/formats/famitracker/specification/memory.py create mode 100644 tests/unit/sampletones_core/formats/famitracker/test_footprint.py diff --git a/docs/development/guidelines.md b/docs/development/guidelines.md index b3085b39..75ff89bd 100644 --- a/docs/development/guidelines.md +++ b/docs/development/guidelines.md @@ -86,6 +86,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. 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. Inherit from `BaseTestSuite` and `BaseTestCase`. 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. Do not assert default values of configurations, layouts, settings, and similar. Defaults are not contracts, and pinning them overconstrains the tests. Test behavior instead: validation bounds, serialization round-trips, and invariants. The exception is when values must match by contract rather than equal a chosen constant — e.g. project metadata at creation or after a save/load round-trip should be asserted to match, never hardcoded to a version string. diff --git a/docs/formats/famitracker.md b/docs/formats/famitracker.md index d0ba9971..f7472ada 100644 --- a/docs/formats/famitracker.md +++ b/docs/formats/famitracker.md @@ -202,3 +202,50 @@ for order slots the song leaves unset; a channel that already fills indices up t 127 leaves no room for it, which the exporter reports rather than emitting a corrupt 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 + +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 +`specification/memory.py` names every field the measurement counts. The instruments panel and +the samples context menu display the result, so the cost of a sample is readable before an +export. + +The **instrument region** holds the instrument list — one pointer per instrument — followed by +each instrument's body: a sequence-enable bitmask, then one pointer per populated sequence. The +**sequence region** holds one chunk per sequence: a four-field header followed by the items. + +| Field | Bytes | Region | +| --- | --- | --- | +| instrument list entry | 2 | instrument | +| sequence-enable bitmask | 1 | instrument | +| sequence pointer, per populated sequence | 2 | instrument | +| item count · loop point · release point · setting | 1 each | sequence | +| item, per tick | 1 | sequence | + +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. +Every populated sequence of one instrument shares a length (section B), so the sequence region +comes to `n · (4 + s)` and an instrument tops out at 777 bytes — three sequences at the 252-item +limit. + +These two figures are the ones FamiTracker itself prints while creating an NSF — +`Instruments used: N (X bytes)` and `Sequences used: M (Y bytes)` — which is how a measurement +is held against the tracker. + +**Version.** The figures are vanilla FamiTracker 0.4.6, the target section A names. The 0CC and +Dn-FamiTracker forks open each instrument body with a channel-type byte, so an instrument costs +one byte more there. + +**Pooling narrows a module's total.** The `SEQUENCES` block stores each distinct sequence once +(section A.2), so a module holding two instruments with the same volume envelope pays for that +chunk once. A per-instrument or per-sample figure states that instrument's own cost, and a +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 shortens the sequences.** A looping instrument shares its shortest dimension's length +and a one-shot its longest (section B), so one set of envelopes costs less as a loop. 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. diff --git a/src/sampletones_core/formats/famitracker/footprint.py b/src/sampletones_core/formats/famitracker/footprint.py new file mode 100644 index 00000000..ffa795ee --- /dev/null +++ b/src/sampletones_core/formats/famitracker/footprint.py @@ -0,0 +1,130 @@ +from dataclasses import dataclass +from typing import Dict, Iterable + +from sampletones_core.constants.enums import GeneratorName +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 +from sampletones_core.formats.famitracker.sequences.features import ( + features_to_instrument_sequences, +) +from sampletones_core.formats.famitracker.specification.memory import ( + INSTRUMENT_DEFINITION_BYTES, + SEQUENCE_HEADER_BYTES, + SEQUENCE_ITEM_BYTES, + SEQUENCE_POINTER_BYTES, +) +from sampletones_core.reconstructions import Reconstruction + + +@dataclass(frozen=True) +class InstrumentFootprint: + """The bytes an instrument occupies once FamiTracker compiles it into an NSF. + + The two fields are the two regions the driver keeps an instrument in, which FamiTracker's + own export log reports side by side: the instrument list and body under ``instrument_bytes``, + the sequence chunks the body points at under ``sequence_bytes``. See + `docs/formats/famitracker.md` for the layout each figure counts. + + Attributes: + instrument_bytes: Bytes the instrument's table entry and body occupy. + sequence_bytes: Bytes the instrument's sequences occupy. + """ + + instrument_bytes: int + sequence_bytes: int + + @property + def total_bytes(self) -> int: + """The whole footprint, the figure a size display names.""" + return self.instrument_bytes + self.sequence_bytes + + +def sequence_footprint(sequence: InstrumentSequence) -> int: + """Measures the bytes one sequence chunk occupies: its four-field header and its items.""" + return SEQUENCE_HEADER_BYTES + SEQUENCE_ITEM_BYTES * len(sequence.items) + + +def sequences_footprint( + sequences: Iterable[InstrumentSequence], +) -> InstrumentFootprint: + """Measures the instrument the given sequences make up. + + A populated sequence earns the instrument a pointer to its chunk and contributes the chunk + itself; an empty one is written as a disabled slot the driver stores nothing for, so the + populated sequences alone decide both figures. + """ + populated = [sequence for sequence in sequences if sequence.enabled] + return InstrumentFootprint( + instrument_bytes=INSTRUMENT_DEFINITION_BYTES + SEQUENCE_POINTER_BYTES * len(populated), + sequence_bytes=sum(sequence_footprint(sequence) for sequence in populated), + ) + + +def instrument_footprint(instrument: Instrument2A03) -> InstrumentFootprint: + """Measures one built instrument, the form an export writes.""" + return sequences_footprint(instrument.sequences.values()) + + +def features_footprint( + features: Features, + *, + loop: bool, +) -> InstrumentFootprint: + """Measures the instrument a generator 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 + sequence holds. + + Args: + features: The per-dimension envelopes describing the slice. + loop: Whether the instrument loops while its note is held, which decides the shared length. + + 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=loop, + ) + return sequences_footprint(sequences.values()) + + +def reconstruction_footprints( + reconstruction: Reconstruction, + *, + loop: bool, +) -> Dict[GeneratorName, InstrumentFootprint]: + """Measures one instrument per channel a reconstruction covers. + + A reconstruction exports one instrument for each of its one to four channels, so the result + holds an entry per covered channel and :func:`total_footprint` sums them into what the whole + sample costs. + + Args: + reconstruction: The reconstruction whose channels are measured. + loop: Whether the sample carrying it loops while its note is held. + + Returns: + Dict[GeneratorName, InstrumentFootprint]: The footprint of each channel's instrument. + """ + return { + generator_name: features_footprint(features, loop=loop) + for generator_name, features in reconstruction.export().items() + } + + +def total_footprint( + footprints: Iterable[InstrumentFootprint], +) -> InstrumentFootprint: + """Sums footprints region by region, giving what a set of instruments costs together.""" + measured = list(footprints) + return InstrumentFootprint( + instrument_bytes=sum(footprint.instrument_bytes for footprint in measured), + sequence_bytes=sum(footprint.sequence_bytes for footprint in measured), + ) diff --git a/src/sampletones_core/formats/famitracker/specification/memory.py b/src/sampletones_core/formats/famitracker/specification/memory.py new file mode 100644 index 00000000..1df6a679 --- /dev/null +++ b/src/sampletones_core/formats/famitracker/specification/memory.py @@ -0,0 +1,15 @@ +from typing import Final + +INSTRUMENT_POINTER_BYTES: Final[int] = 2 +SEQUENCE_ENABLE_MASK_BYTES: Final[int] = 1 +SEQUENCE_POINTER_BYTES: Final[int] = 2 +INSTRUMENT_DEFINITION_BYTES: Final[int] = INSTRUMENT_POINTER_BYTES + SEQUENCE_ENABLE_MASK_BYTES + +SEQUENCE_LENGTH_BYTES: Final[int] = 1 +SEQUENCE_LOOP_POINT_BYTES: Final[int] = 1 +SEQUENCE_RELEASE_POINT_BYTES: Final[int] = 1 +SEQUENCE_SETTING_BYTES: Final[int] = 1 +SEQUENCE_ITEM_BYTES: Final[int] = 1 +SEQUENCE_HEADER_BYTES: Final[int] = ( + SEQUENCE_LENGTH_BYTES + SEQUENCE_LOOP_POINT_BYTES + SEQUENCE_RELEASE_POINT_BYTES + SEQUENCE_SETTING_BYTES +) diff --git a/tests/unit/sampletones_core/formats/famitracker/test_footprint.py b/tests/unit/sampletones_core/formats/famitracker/test_footprint.py new file mode 100644 index 00000000..d4fecae7 --- /dev/null +++ b/tests/unit/sampletones_core/formats/famitracker/test_footprint.py @@ -0,0 +1,182 @@ +from dataclasses import dataclass +from typing import Final, Optional, Sequence + +import numpy as np +import pytest + +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.exporters.feature import Features +from sampletones_core.formats.famitracker.builder import build_instrument +from sampletones_core.formats.famitracker.footprint import ( + InstrumentFootprint, + features_footprint, + instrument_footprint, + reconstruction_footprints, + sequence_footprint, + sequences_footprint, + total_footprint, +) +from sampletones_core.formats.famitracker.model.sequence import InstrumentSequence +from sampletones_core.formats.famitracker.specification.memory import ( + INSTRUMENT_DEFINITION_BYTES, + SEQUENCE_HEADER_BYTES, + SEQUENCE_POINTER_BYTES, +) +from sampletones_core.formats.famitracker.specification.sequences import ( + MAX_SEQUENCE_ITEMS, + SequenceKind, +) +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + +from .conftest import dual_generator_sample, pulse_sample + +REFERENCE_PITCH: Final[int] = 60 +OVER_LONG_LENGTH: Final[int] = MAX_SEQUENCE_ITEMS + 48 + + +def build_features( + volume: Sequence[int], + arpeggio: Sequence[int], + duty_cycle: Optional[Sequence[int]], +) -> Features: + """Builds the envelopes of one generator slice, leaving the pitch dimensions unused.""" + return Features( + initial_pitch=REFERENCE_PITCH, + volume=np.array(volume, dtype=int), + arpeggio=np.array(arpeggio, dtype=int), + pitch=None, + hi_pitch=None, + duty_cycle=None if duty_cycle is None else np.array(duty_cycle, dtype=int), + ) + + +class TestFeaturesFootprint(BaseTestSuite): + @dataclass(frozen=True, kw_only=True) + class FootprintCase(BaseRegularTestCase): + features: Features + loop: bool + expected: InstrumentFootprint + + test_cases = ( + FootprintCase( + features=build_features([15, 12, 9, 0], [0, 2, 4], [1, 1, 2]), + loop=False, + expected=InstrumentFootprint(instrument_bytes=9, sequence_bytes=24), + label="pulse_one_shot", + ), + FootprintCase( + features=build_features([15, 12, 9, 0], [0, 2, 4], [1, 1, 2]), + loop=True, + expected=InstrumentFootprint(instrument_bytes=9, sequence_bytes=21), + label="pulse_loop", + ), + FootprintCase( + features=build_features([15, 12, 0], [0, 1], None), + loop=False, + expected=InstrumentFootprint(instrument_bytes=7, sequence_bytes=14), + label="triangle", + ), + FootprintCase( + features=build_features([], [], None), + loop=False, + expected=InstrumentFootprint(instrument_bytes=3, sequence_bytes=0), + label="silent", + ), + FootprintCase( + features=build_features( + list(range(OVER_LONG_LENGTH)), + [0] * OVER_LONG_LENGTH, + None, + ), + loop=False, + expected=InstrumentFootprint(instrument_bytes=7, sequence_bytes=512), + label="capped_at_the_sequence_limit", + ), + FootprintCase( + features=build_features( + [0] * MAX_SEQUENCE_ITEMS, + [0] * MAX_SEQUENCE_ITEMS, + [0] * MAX_SEQUENCE_ITEMS, + ), + loop=False, + expected=InstrumentFootprint(instrument_bytes=9, sequence_bytes=768), + label="largest_instrument_famitracker_holds", + ), + ) + + @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label) + def test_both_regions_are_measured_from_the_populated_sequences( + self, + case: FootprintCase, + ) -> None: + assert features_footprint(case.features, loop=case.loop) == case.expected + + @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label) + def test_the_built_instrument_measures_the_same(self, case: FootprintCase) -> None: + """Both entry points measure one export, so a slice reads the same either way.""" + instrument = build_instrument(0, case.label, case.features, loop=case.loop) + assert instrument_footprint(instrument) == case.expected + + @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label) + def test_the_total_sums_both_regions(self, case: FootprintCase) -> None: + footprint = features_footprint(case.features, loop=case.loop) + assert footprint.total_bytes == case.expected.instrument_bytes + case.expected.sequence_bytes + + +class TestSequenceFootprint: + def test_a_sequence_holds_its_header_and_one_byte_per_item(self) -> None: + sequence = InstrumentSequence(kind=SequenceKind.VOLUME, items=(15, 12, 9)) + assert sequence_footprint(sequence) == SEQUENCE_HEADER_BYTES + 3 + + def test_a_disabled_sequence_costs_nothing(self) -> None: + sequences = ( + InstrumentSequence(kind=SequenceKind.VOLUME, items=(15, 12)), + InstrumentSequence(kind=SequenceKind.PITCH, items=()), + ) + footprint = sequences_footprint(sequences) + assert footprint.instrument_bytes == INSTRUMENT_DEFINITION_BYTES + SEQUENCE_POINTER_BYTES + assert footprint.sequence_bytes == SEQUENCE_HEADER_BYTES + 2 + + +class TestTotalFootprint: + def test_regions_are_summed_separately(self) -> None: + footprints = ( + InstrumentFootprint(instrument_bytes=9, sequence_bytes=24), + InstrumentFootprint(instrument_bytes=7, sequence_bytes=16), + ) + assert total_footprint(footprints) == InstrumentFootprint(instrument_bytes=16, sequence_bytes=40) + + def test_no_instruments_cost_nothing(self) -> None: + assert total_footprint(()) == InstrumentFootprint(instrument_bytes=0, sequence_bytes=0) + + +class TestReconstructionFootprints: + def test_one_entry_per_covered_channel(self) -> None: + 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} + + 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] + assert pulse.instrument_bytes - triangle.instrument_bytes == SEQUENCE_POINTER_BYTES + + 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) == { + generator_name: features_footprint(feature, loop=loop) for generator_name, feature in features.items() + } + + 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()) + assert one_shot.instrument_bytes == looping.instrument_bytes + assert looping.sequence_bytes < one_shot.sequence_bytes From 9b468c98e8a9fa87d6ae4030841336dd0f23f788 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 12 Aug 2026 19:20:34 +0200 Subject: [PATCH 02/11] Added: instrument and sample size to the instruments panel --- docs/formats/famitracker.md | 41 +++-- .../coordinators/tabs/reconstruction.py | 1 + .../logic/reconstruction/instruments.py | 112 +++++++++++-- .../tags/reconstructions.py | 7 + .../reconstruction/instruments/instruments.py | 78 +++++++++ .../view_model/reconstruction/instruments.py | 4 +- .../view_model/shared/footprint.py | 55 ++++++ src/sampletones_config/lang/en.yaml | 3 + src/sampletones_core/exporters/lengths.py | 44 ++++- .../formats/famitracker/sequences/features.py | 45 +++-- .../logic/reconstruction/test_instruments.py | 135 +++++++++++++++ .../reconstruction/test_instruments_panel.py | 157 +++++++++++++++++- .../exporters/test_lengths.py | 30 +++- .../famitracker/sequences/test_features.py | 41 +++-- .../formats/famitracker/test_footprint.py | 10 +- .../formats/famitracker/test_fti.py | 12 +- 16 files changed, 701 insertions(+), 74 deletions(-) create mode 100644 src/sampletones_application/view_model/shared/footprint.py diff --git a/docs/formats/famitracker.md b/docs/formats/famitracker.md index f7472ada..978fbb9e 100644 --- a/docs/formats/famitracker.md +++ b/docs/formats/famitracker.md @@ -146,16 +146,23 @@ sequence, so its envelopes repeat from the start while the note is held; a one-s instrument leaves every loop point at `-1`. A sample's `loop` flag drives this when the sample is exported into a module. -**Equal lengths.** FamiTracker advances each sequence on its own per-tick counter, so -every populated sequence of an instrument carries the same item count and the -dimensions stay in step. The volume envelope arrives one item longer than the others, -carrying a trailing zero that releases the note. A looping instrument therefore keeps -the shortest length, dropping that trailing item so the loop sustains; a one-shot -keeps the longest, each shorter dimension holding its final value through the release -tick. That shared 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 passes that length, so the limit is visible before an export. +**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. + +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 +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 +one carrying a single zero: the disabled slot leaves that dimension to the channel, while a +one-item sequence sets the value once and holds it. **How _SampleToNES_ fills an instrument.** Each generator slice of a sample's reconstruction becomes one instrument, so a sample yields one to four instruments. @@ -227,9 +234,8 @@ An instrument with `n` populated sequences carrying `s₁ … sₙ` items theref `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. -Every populated sequence of one instrument shares a length (section B), so the sequence region -comes to `n · (4 + s)` and an instrument tops out at 777 bytes — three sequences at the 252-item -limit. +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. These two figures are the ones FamiTracker itself prints while creating an NSF — `Instruments used: N (X bytes)` and `Sequences used: M (Y bytes)` — which is how a measurement @@ -245,7 +251,8 @@ 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 shortens the sequences.** A looping instrument shares its shortest dimension's length -and a one-shot its longest (section B), so one set of envelopes costs less as a loop. 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. +**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. diff --git a/src/sampletones_application/coordinators/tabs/reconstruction.py b/src/sampletones_application/coordinators/tabs/reconstruction.py index d8eb9897..b11a114a 100644 --- a/src/sampletones_application/coordinators/tabs/reconstruction.py +++ b/src/sampletones_application/coordinators/tabs/reconstruction.py @@ -611,6 +611,7 @@ def _remove_directory(self, directory: Path) -> None: def update_reconstruction(self) -> None: self._reconstruction_panel_logic.update_reconstruction() + self._reconstruction_instruments_logic.refresh_footprint() def set_reconstruction_dimmed(self, dimmed: bool) -> None: self._reconstruction_plot_panel.set_reconstruction_dimmed(dimmed) diff --git a/src/sampletones_application/logic/reconstruction/instruments.py b/src/sampletones_application/logic/reconstruction/instruments.py index 674d5e78..ac259d1c 100644 --- a/src/sampletones_application/logic/reconstruction/instruments.py +++ b/src/sampletones_application/logic/reconstruction/instruments.py @@ -2,7 +2,9 @@ import numpy as np -from sampletones_application.layout.behavior.scheduling.scheduling import SchedulingBehavior +from sampletones_application.layout.behavior.scheduling.scheduling import ( + SchedulingBehavior, +) from sampletones_application.logic.reconstruction.manager import ReconstructionManager from sampletones_application.utils.callbacks.queue import CallbackQueue from sampletones_application.view_model.reconstruction.instruments import ( @@ -11,8 +13,10 @@ from sampletones_application.view_model.reconstruction.update import ( ReconstructionUpdate, ) +from sampletones_application.view_model.shared.footprint import SampleFootprintViewModel from sampletones_core.constants.enums import FeatureKey, GeneratorName 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 @@ -39,27 +43,57 @@ def __init__( 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) + + def refresh_footprint(self) -> None: + """Reports the sizes the loaded envelopes occupy, leaving the displayed envelopes as they are. + + A regeneration replaces what an instrument exports, so the byte figures 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())) + + def _current_generators(self) -> Optional[Dict[GeneratorName, Features]]: feature_data = self.reconstruction_manager.current_features - if feature_data is None: - self.call( - self.on_view_changed, - ReconstructionInstrumentsViewModel( - reconstruction_loaded=False, - available_generators=frozenset(), - ), + return None if feature_data is None else feature_data.generators + + def _build_view_model( + self, + generators: Optional[Dict[GeneratorName, Features]], + ) -> ReconstructionInstrumentsViewModel: + if generators is None: + return ReconstructionInstrumentsViewModel( + reconstruction_loaded=False, + available_generators=frozenset(), + footprint=None, ) - self.call(self.on_feature_data_changed, None) - return - available_generators: FrozenSet[GeneratorName] = frozenset(feature_data.generators.keys()) - self.call( - self.on_view_changed, - ReconstructionInstrumentsViewModel( - reconstruction_loaded=True, - available_generators=available_generators, - ), + available_generators: FrozenSet[GeneratorName] = frozenset(generators.keys()) + return ReconstructionInstrumentsViewModel( + reconstruction_loaded=True, + available_generators=available_generators, + footprint=self._build_footprint(generators), + ) + + def _build_footprint( + self, + generators: Dict[GeneratorName, Features], + ) -> SampleFootprintViewModel: + """Measures each 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 + project — so each instrument is measured playing its envelopes once, matching what + **Export instrument...** produces. + """ + return SampleFootprintViewModel.from_footprints( + { + generator_name: features_footprint(features, loop=False) + for generator_name, features in generators.items() + } ) - self.call(self.on_feature_data_changed, feature_data.generators) def handle_pitch_value_changed( self, @@ -80,6 +114,7 @@ def handle_bar_point_clicked( feature_key: FeatureKey, data: np.ndarray, ) -> None: + self._report_edited_size(generator_name, feature_key, data) self._schedule_reconstruction_update( ReconstructionUpdate( generator_name, @@ -94,6 +129,7 @@ def handle_raw_data_changed( feature_key: FeatureKey, data: np.ndarray, ) -> None: + self._report_edited_size(generator_name, feature_key, data) self._schedule_reconstruction_update( ReconstructionUpdate( generator_name, @@ -102,6 +138,46 @@ def handle_raw_data_changed( ) ) + def _report_edited_size( + self, + generator_name: GeneratorName, + feature_key: FeatureKey, + data: np.ndarray, + ) -> None: + """Reports what the edited envelope costs as the edit arrives, ahead of its regeneration. + + Measuring the envelope the user just wrote keeps the figures answering what is on screen + 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: + return + + self.call( + self.on_view_changed, + self._build_view_model( + self._with_edit( + generators, + generator_name, + feature_key, + data, + ) + ), + ) + + def _with_edit( + self, + generators: Dict[GeneratorName, Features], + generator_name: GeneratorName, + feature_key: FeatureKey, + data: np.ndarray, + ) -> Dict[GeneratorName, Features]: + """The loaded channels with one envelope replaced, leaving the loaded ones as they are.""" + edited = generators[generator_name].model_copy(deep=True) + edited[feature_key] = data + return {**generators, generator_name: edited} + def _schedule_reconstruction_update( self, update: ReconstructionUpdate, diff --git a/src/sampletones_application/tags/reconstructions.py b/src/sampletones_application/tags/reconstructions.py index 86e6599b..90d3f9e0 100644 --- a/src/sampletones_application/tags/reconstructions.py +++ b/src/sampletones_application/tags/reconstructions.py @@ -122,8 +122,15 @@ Widget.BUTTON, "export_instrument", ) +TAG_RECONSTRUCTIONS_INSTRUMENTS_TEXT_SAMPLE_SIZE = TagName( + Page.RECONSTRUCTIONS, + Panel.INSTRUMENTS, + Widget.TEXT, + "sample_size", +) PRE_RECONSTRUCTION_GENERATOR = compose_tag("reconstruction", "generator") SUF_RECONSTRUCTIONS_INSTRUMENTS_NO_DATA_MESSAGE = "no_data_message" +SUF_RECONSTRUCTIONS_INSTRUMENTS_INSTRUMENT_SIZE = "instrument_size" SUF_RECONSTRUCTIONS_INSTRUMENTS_WINDOW = "window" SUF_RECONSTRUCTIONS_RECONSTRUCTION_AUTOSCALE = "autoscale" diff --git a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py index b510122e..703e998f 100644 --- a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py +++ b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py @@ -25,13 +25,16 @@ SUF_GRAPH_RAW_DATA, ) from sampletones_application.tags.reconstructions import ( + SUF_RECONSTRUCTIONS_INSTRUMENTS_INSTRUMENT_SIZE, SUF_RECONSTRUCTIONS_INSTRUMENTS_NO_DATA_MESSAGE, SUF_RECONSTRUCTIONS_INSTRUMENTS_WINDOW, TAG_RECONSTRUCTIONS_INSTRUMENTS_BUTTON_EXPORT_INSTRUMENT, TAG_RECONSTRUCTIONS_INSTRUMENTS_PANEL, TAG_RECONSTRUCTIONS_INSTRUMENTS_TABS_BAR, + TAG_RECONSTRUCTIONS_INSTRUMENTS_TEXT_SAMPLE_SIZE, ) from sampletones_application.ui.elements.button import GUIButton +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.graphs.bar import GUIBarGraph @@ -54,9 +57,11 @@ dpg_configure_item, dpg_set_value, ) +from sampletones_application.utils.gui.palette.dpg import dpg_set_palette_color 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, @@ -103,6 +108,8 @@ def __init__( 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) 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._graphs: Dict[str, GUIBarGraph] = {} self._sequence_lengths: Dict[Tuple[GeneratorName, FeatureKey], int] = {} @@ -126,6 +133,9 @@ def __init__( self.on_raw_data_changed: Optional[Callable[[GeneratorName, FeatureKey, np.ndarray], None]] = None self._lbl_copy = language_manager["reconstructions.instruments.label.copy_button"] + self._lbl_sample_size = language_manager["global.context.label.sample_size"] + self._lbl_instrument_size = language_manager["global.context.label.instrument_size"] + self._tpl_size_bytes = language_manager["global.context.template.size_bytes"] tooltip_template = language_manager["reconstructions.instruments.template.initial_pitch_tooltip_template"] self._pitch_tooltip = build_pitch_tooltip( language_manager, @@ -179,6 +189,17 @@ def _create_content(self) -> None: show=True, ) + with dpg.group( + tag=self.sample_size_group_tag, + parent=self._body_container, + show=False, + ): + self._create_size_field( + self._lbl_sample_size, + self.sample_size_tag, + self.sample_size_group_tag, + ) + with dpg.tab_bar( tag=self.tab_bar_tag, parent=self._body_container, @@ -186,9 +207,37 @@ def _create_content(self) -> None: ): self._create_tabs_for_generators() + def _create_size_field( + self, + label: str, + value_tag: str, + parent: str, + ) -> None: + """Draws a read-only byte figure, styled as the pitch stepper's readout is. + + 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. + """ + with labeled_field( + label, + self._pitch_stepper_style.dimensions.label_width, + parent=parent, + ): + dpg.add_text(tag=value_tag, default_value="") + dpg_set_palette_color(value_tag, self._pitch_stepper_style.value_color) + FontRegistry.bind_to_item(value_tag, Font.MONO) + def _get_generator_tab_tag(self, generator_name: GeneratorName) -> str: return compose_tag(self.tab_bar_tag, generator_name) + def _get_instrument_size_tag(self, generator_name: GeneratorName) -> str: + return compose_tag( + self.tab_bar_tag, + generator_name, + SUF_RECONSTRUCTIONS_INSTRUMENTS_INSTRUMENT_SIZE, + ) + def _get_window_tag(self, tab_tag: str) -> str: return compose_tag(tab_tag, SUF_RECONSTRUCTIONS_INSTRUMENTS_WINDOW) @@ -288,6 +337,11 @@ def _create_generator_content( window_tag: str, ) -> None: initial_pitch = self._default_initial_pitch(generator_name) + self._create_size_field( + self._lbl_instrument_size, + self._get_instrument_size_tag(generator_name), + window_tag, + ) self._create_pitch_stepper(generator_name, initial_pitch, window_tag) self._create_generator_feature_displays(generator_name, window_tag) @@ -384,12 +438,36 @@ def update_view( 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) for generator_name in GeneratorName.items(): tab_tag = self._get_generator_tab_tag(generator_name) is_available = generator_name in view_model.available_generators dpg_configure_item(tab_tag, show=is_available) + def _update_sizes( + self, + footprint: Optional[SampleFootprintViewModel], + ) -> None: + """Writes the byte figures the loaded reconstruction occupies, the sample's and each channel's.""" + if footprint is None: + 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) + if instrument_bytes is None: + continue + + dpg_set_value( + self._get_instrument_size_tag(generator_name), + self._format_size(instrument_bytes), + ) + + def _format_size(self, byte_count: int) -> str: + return self._tpl_size_bytes.format(bytes=byte_count) + def update_feature_data( self, generators: Optional[Dict[GeneratorName, Features]], diff --git a/src/sampletones_application/view_model/reconstruction/instruments.py b/src/sampletones_application/view_model/reconstruction/instruments.py index c599abe1..9b3a955b 100644 --- a/src/sampletones_application/view_model/reconstruction/instruments.py +++ b/src/sampletones_application/view_model/reconstruction/instruments.py @@ -1,10 +1,12 @@ -from typing import FrozenSet +from typing import FrozenSet, Optional from pydantic import BaseModel +from sampletones_application.view_model.shared.footprint import SampleFootprintViewModel from sampletones_core.constants.enums import GeneratorName class ReconstructionInstrumentsViewModel(BaseModel, frozen=True): reconstruction_loaded: bool available_generators: FrozenSet[GeneratorName] + footprint: Optional[SampleFootprintViewModel] diff --git a/src/sampletones_application/view_model/shared/footprint.py b/src/sampletones_application/view_model/shared/footprint.py new file mode 100644 index 00000000..4607001c --- /dev/null +++ b/src/sampletones_application/view_model/shared/footprint.py @@ -0,0 +1,55 @@ +from typing import Dict, Optional, Self, Tuple + +from pydantic import BaseModel + +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.formats.famitracker.footprint import InstrumentFootprint + + +class InstrumentSizeViewModel(BaseModel, frozen=True): + """The bytes one channel's instrument occupies once a tracker compiles it.""" + + generator: GeneratorName + total_bytes: int + + +class SampleFootprintViewModel(BaseModel, frozen=True): + """The byte sizes a sample's instruments occupy, one entry per channel it covers. + + 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. + """ + + instruments: Tuple[InstrumentSizeViewModel, ...] + + @classmethod + def from_footprints( + cls, + footprints: Dict[GeneratorName, InstrumentFootprint], + ) -> Self: + """Collects measured channels in the generators' own order, so displays list them alike.""" + return cls( + instruments=tuple( + InstrumentSizeViewModel( + generator=generator_name, + total_bytes=footprints[generator_name].total_bytes, + ) + for generator_name in GeneratorName.items() + if generator_name in footprints + ), + ) + + @property + def total_bytes(self) -> int: + """The bytes the whole sample occupies, its instruments summed.""" + return sum(instrument.total_bytes for instrument in self.instruments) + + def bytes_for(self, generator: GeneratorName) -> Optional[int]: + """The bytes one channel's instrument occupies, where the sample covers that channel.""" + for instrument in self.instruments: + if instrument.generator == generator: + return instrument.total_bytes + + return None diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 827d625b..d91fe3ac 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -157,6 +157,9 @@ global.context.label.detail_spectrum_method: "Generation method" global.context.label.detail_transformation_gamma: "Transformation gamma" global.context.label.detail_window_size: "Window size" global.context.label.detail_configuration: "Configuration" +global.context.label.instrument_size: "Instrument size" +global.context.label.sample_size: "Sample size" +global.context.template.size_bytes: "{bytes} B" # ============================================================================= # Global — Menu diff --git a/src/sampletones_core/exporters/lengths.py b/src/sampletones_core/exporters/lengths.py index 64f6144d..fc5ff151 100644 --- a/src/sampletones_core/exporters/lengths.py +++ b/src/sampletones_core/exporters/lengths.py @@ -11,6 +11,15 @@ def _resize(items: Tuple[int, ...], length: int) -> Tuple[int, ...]: 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.warning(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. @@ -28,12 +37,37 @@ def _common_length(lengths: List[int], loop: bool, limit: Optional[int]) -> int: Returns: int: The shared item count, at most ``limit`` where one applies. """ - length = min(lengths) if loop else max(lengths) - if limit is None or length <= limit: - return length + return _limited_length(min(lengths) if loop else max(lengths), limit) - logger.warning(f"Instrument envelope of {length} items keeps its first {limit}, the most the format holds") - return 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( diff --git a/src/sampletones_core/formats/famitracker/sequences/features.py b/src/sampletones_core/formats/famitracker/sequences/features.py index 75d88510..24e2057f 100644 --- a/src/sampletones_core/formats/famitracker/sequences/features.py +++ b/src/sampletones_core/formats/famitracker/sequences/features.py @@ -2,7 +2,7 @@ import numpy as np -from sampletones_core.exporters.lengths import equalize_lengths +from sampletones_core.exporters.lengths import equalize_lengths, limit_lengths from sampletones_core.formats.famitracker.model.sequence import InstrumentSequence from sampletones_core.formats.famitracker.specification.sequences import ( LOOP_FROM_START, @@ -18,6 +18,25 @@ def _to_items(array: Optional[np.ndarray]) -> Tuple[int, ...]: return tuple(int(value) for value in array) +def _sequence_items( + arrays: Dict[SequenceKind, Optional[np.ndarray]], + loop: bool, +) -> Dict[SequenceKind, Tuple[int, ...]]: + """Reads the dimensions as the item tuples an instrument stores. + + 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. + """ + 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) + + return limit_lengths(items_by_kind, limit=MAX_SEQUENCE_ITEMS) + + def features_to_instrument_sequences( *, volume: np.ndarray, @@ -29,12 +48,12 @@ def features_to_instrument_sequences( ) -> Dict[SequenceKind, InstrumentSequence]: """Builds the five 2A03 sequences from per-dimension envelope arrays. - Each dimension becomes an :class:`InstrumentSequence`; a dimension passed as - ``None`` becomes a disabled (empty) sequence. Populated dimensions are brought to a - common length so they stay in step tick for tick, capped at the ``MAX_SEQUENCE_ITEMS`` - items FamiTracker stores, 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. + 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. """ arrays: Dict[SequenceKind, Optional[np.ndarray]] = { SequenceKind.VOLUME: volume, @@ -44,15 +63,15 @@ def features_to_instrument_sequences( SequenceKind.DUTY: duty_cycle, } - items_by_kind = equalize_lengths( - {kind: _to_items(array) for kind, array in arrays.items()}, - loop, - limit=MAX_SEQUENCE_ITEMS, - ) + items_by_kind = _sequence_items(arrays, loop) 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) + sequences[kind] = InstrumentSequence( + kind=kind, + items=items, + loop_point=loop_point, + ) return sequences diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py b/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py index f8fd4aa8..625f1348 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py @@ -15,6 +15,10 @@ ) from sampletones_core.constants.enums import FeatureKey, GeneratorName from sampletones_core.exporters import Features +from sampletones_core.formats.famitracker.footprint import ( + features_footprint, + total_footprint, +) from sampletones_core.reconstructions import Reconstruction @@ -96,6 +100,137 @@ def test_with_features_exposes_available_generators( assert GeneratorName.PULSE1 in received[0].available_generators +class TestReconstructionInstrumentsLogicFootprint: + """The byte figures the view carries, measured from the envelopes the manager holds.""" + + def test_no_reconstruction_carries_no_footprint( + self, + instruments_logic: ReconstructionInstrumentsLogic, + mock_reconstruction_manager: MagicMock, + ) -> None: + mock_reconstruction_manager.current_features = None + received: List[ReconstructionInstrumentsViewModel] = [] + instruments_logic.on_view_changed = received.append + instruments_logic.update_display() + assert received[0].footprint is None + + def test_every_covered_channel_is_measured( + 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.update_display() + footprint = received[0].footprint + assert footprint is not None + assert {instrument.generator for instrument in footprint.instruments} == set(feature_data.generators) + + def test_the_size_is_the_one_a_one_shot_export_writes( + self, + instruments_logic: ReconstructionInstrumentsLogic, + mock_reconstruction_manager: MagicMock, + reconstruction_factory: Callable[[], Reconstruction], + ) -> None: + """A reconstruction exports its instruments as one-shots, so that is the size shown.""" + 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.update_display() + footprint = received[0].footprint + assert footprint is not None + expected = total_footprint( + features_footprint(features, loop=False) for features in feature_data.generators.values() + ) + assert footprint.total_bytes == expected.total_bytes + + def test_an_envelope_edit_is_measured_as_it_arrives( + self, + instruments_logic: ReconstructionInstrumentsLogic, + mock_reconstruction_manager: MagicMock, + reconstruction_factory: Callable[[], Reconstruction], + ) -> None: + """The typed envelope is measured at once, so the figure answers what is on screen.""" + feature_data = FeatureData.load(reconstruction_factory()) + mock_reconstruction_manager.current_features = feature_data + 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( + GeneratorName.PULSE1, + FeatureKey.VOLUME, + volume, + ) + + edited = feature_data.generators[GeneratorName.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 + + 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( + GeneratorName.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.""" + feature_data = FeatureData.load(reconstruction_factory()) + mock_reconstruction_manager.current_features = feature_data + loaded_volume = feature_data.generators[GeneratorName.PULSE1].volume.copy() + + instruments_logic.handle_raw_data_changed( + GeneratorName.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) + + def test_a_refresh_reports_the_view_alone( + self, + instruments_logic: ReconstructionInstrumentsLogic, + mock_reconstruction_manager: MagicMock, + reconstruction_factory: Callable[[], Reconstruction], + ) -> None: + """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]]] = [] + instruments_logic.on_view_changed = received.append + instruments_logic.on_feature_data_changed = feature_updates.append + + instruments_logic.refresh_footprint() + + assert len(received) == 1 + assert received[0].footprint is not None + assert feature_updates == [] + + class TestReconstructionInstrumentsLogicHandlePitchValueChanged: def test_schedules_reconstruction_update( self, 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 7458f13a..962ee4d0 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,4 +1,5 @@ -from typing import Final, List +from dataclasses import dataclass +from typing import Dict, Final, List from unittest.mock import MagicMock import pytest @@ -19,6 +20,7 @@ ) 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, ) @@ -26,13 +28,44 @@ 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.shared.footprint import ( + InstrumentSizeViewModel, + SampleFootprintViewModel, +) from sampletones_core.constants.enums import FeatureKey, GeneratorName from sampletones_core.formats.famitracker.specification.sequences import ( MAX_SEQUENCE_ITEMS, ) +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase SEQUENCE_STATUS_KEY: Final[str] = "reconstructions.instruments.message.status_sequence" +NOT_LOADED: Final[ReconstructionInstrumentsViewModel] = ReconstructionInstrumentsViewModel( + reconstruction_loaded=False, + available_generators=frozenset(), + footprint=None, +) + + +def build_view_model( + channel_bytes: Dict[GeneratorName, int], +) -> ReconstructionInstrumentsViewModel: + """A loaded reconstruction covering the given channels, each measured at the given size.""" + return ReconstructionInstrumentsViewModel( + reconstruction_loaded=True, + available_generators=frozenset(channel_bytes), + footprint=SampleFootprintViewModel( + instruments=tuple( + InstrumentSizeViewModel(generator=generator_name, total_bytes=byte_count) + for generator_name, byte_count in channel_bytes.items() + ), + ), + ) + @pytest.fixture def layout_config() -> LayoutConfig: @@ -59,6 +92,26 @@ def bound_themes(monkeypatch: pytest.MonkeyPatch) -> List[str]: return tags +@pytest.fixture +def written(monkeypatch: pytest.MonkeyPatch) -> Dict[str, str]: + """Records the texts written to items, standing in for the DPG values.""" + values: Dict[str, str] = {} + monkeypatch.setattr(instruments_module, "dpg_set_value", values.__setitem__) + return values + + +@pytest.fixture +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 + + monkeypatch.setattr(instruments_module, "dpg_configure_item", configure) + return flags + + @pytest.fixture def panel(layout_config: LayoutConfig) -> GUIReconstructionInstrumentsPanel: return GUIReconstructionInstrumentsPanel( @@ -179,3 +232,105 @@ def test_a_sequence_beyond_the_limit_names_the_limit( message = panel._sequence_status_message(GeneratorName.PULSE1, FeatureKey.VOLUME) assert "300" in message assert str(MAX_SEQUENCE_ITEMS) in message + + +class TestSizeFields(BaseTestSuite): + """The two read-only byte figures: the sample's above the tabs, each channel's inside its tab.""" + + @dataclass(frozen=True, kw_only=True) + class SizeCase(BaseRegularTestCase): + channel_bytes: Dict[GeneratorName, int] + expected: str + + test_cases = ( + SizeCase( + label="a single channel spends what its instrument does", + channel_bytes={GeneratorName.PULSE1: 777}, + expected="777 B", + ), + SizeCase( + label="three channels spend their instruments together", + channel_bytes={ + GeneratorName.PULSE1: 777, + GeneratorName.TRIANGLE: 519, + GeneratorName.NOISE: 777, + }, + expected="2073 B", + ), + SizeCase( + label="a silent channel spends the instrument definition alone", + channel_bytes={GeneratorName.TRIANGLE: 3}, + expected="3 B", + ), + ) + + @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label) + def test_the_sample_size_sums_its_channels( + self, + panel: GUIReconstructionInstrumentsPanel, + written: Dict[str, str], + shown: Dict[str, bool], + case: SizeCase, + ) -> None: + panel.update_view(build_view_model(case.channel_bytes)) + assert written[panel.sample_size_tag] == case.expected + + @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label) + def test_each_channel_states_its_own_size( + self, + panel: GUIReconstructionInstrumentsPanel, + written: Dict[str, str], + shown: Dict[str, bool], + case: SizeCase, + ) -> None: + panel.update_view(build_view_model(case.channel_bytes)) + assert { + generator_name: written[panel._get_instrument_size_tag(generator_name)] + for generator_name in case.channel_bytes + } == {generator_name: f"{byte_count} B" for generator_name, byte_count in case.channel_bytes.items()} + + @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label) + def test_an_uncovered_channel_is_left_alone( + self, + panel: GUIReconstructionInstrumentsPanel, + written: Dict[str, str], + shown: Dict[str, bool], + case: SizeCase, + ) -> None: + """A channel the reconstruction leaves out exports no instrument, so its tab holds no figure.""" + panel.update_view(build_view_model(case.channel_bytes)) + uncovered = [ + panel._get_instrument_size_tag(generator_name) + for generator_name in GeneratorName.items() + if generator_name not in case.channel_bytes + ] + assert [tag for tag in uncovered if tag in written] == [] + + +class TestSizeVisibility: + def test_a_loaded_reconstruction_shows_the_sample_size( + self, + panel: GUIReconstructionInstrumentsPanel, + written: Dict[str, str], + shown: Dict[str, bool], + ) -> None: + panel.update_view(build_view_model({GeneratorName.PULSE1: 777})) + assert shown[panel.sample_size_group_tag] is True + + def test_no_reconstruction_hides_the_sample_size( + self, + panel: GUIReconstructionInstrumentsPanel, + written: Dict[str, str], + shown: Dict[str, bool], + ) -> None: + panel.update_view(NOT_LOADED) + assert shown[panel.sample_size_group_tag] is False + + def test_no_reconstruction_states_no_figures( + self, + panel: GUIReconstructionInstrumentsPanel, + written: Dict[str, str], + shown: Dict[str, bool], + ) -> None: + panel.update_view(NOT_LOADED) + assert written == {} diff --git a/tests/unit/sampletones_core/exporters/test_lengths.py b/tests/unit/sampletones_core/exporters/test_lengths.py index b3c4bb0c..785563b0 100644 --- a/tests/unit/sampletones_core/exporters/test_lengths.py +++ b/tests/unit/sampletones_core/exporters/test_lengths.py @@ -3,7 +3,7 @@ import pytest -from sampletones_core.exporters.lengths import equalize_lengths +from sampletones_core.exporters.lengths import equalize_lengths, limit_lengths VOLUME: Final[str] = "volume" ARPEGGIO: Final[str] = "arpeggio" @@ -44,6 +44,34 @@ def test_all_dimensions_empty_stay_empty(self) -> None: 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.WARNING): + 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): + 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: 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 20d8e26b..9f625a53 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,4 @@ import numpy as np -import pytest from sampletones_core.formats.famitracker.sequences.features import ( features_to_instrument_sequences, @@ -97,7 +96,7 @@ def test_no_loop_leaves_loop_point_disabled(self) -> None: assert sequences[SequenceKind.VOLUME].loop_point == NO_LOOP_POINT -class TestSequenceLengthsAreEqualized: +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]), @@ -111,31 +110,31 @@ def test_loop_drops_the_trailing_note_off_volume_item(self) -> None: assert sequences[SequenceKind.ARPEGGIO].items == (0, 2, 4) assert sequences[SequenceKind.DUTY].items == (1, 1, 2) - def test_one_shot_holds_the_shorter_dimensions_final_value(self) -> None: + def test_one_shot_carries_each_dimension_as_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, 1, 2]), + duty_cycle=np.array([1]), loop=False, ) assert sequences[SequenceKind.VOLUME].items == (15, 12, 9, 0) - assert sequences[SequenceKind.ARPEGGIO].items == (0, 2, 4, 4) - assert sequences[SequenceKind.DUTY].items == (1, 1, 2, 2) + assert sequences[SequenceKind.ARPEGGIO].items == (0, 2, 4) + assert sequences[SequenceKind.DUTY].items == (1,) - @pytest.mark.parametrize("loop", [False, True], ids=["one_shot", "loop"]) - def test_every_populated_dimension_shares_one_length(self, loop: bool) -> None: + def test_a_loop_brings_every_populated_dimension_to_one_length(self) -> None: 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=loop, + loop=True, ) lengths = {len(sequence.items) for sequence in sequences.values() if sequence.enabled} - assert len(lengths) == 1 + assert lengths == {2} def test_disabled_dimensions_stay_empty(self) -> None: sequences = features_to_instrument_sequences( @@ -149,6 +148,28 @@ def test_disabled_dimensions_stay_empty(self) -> None: 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=False, + ) + zeroed = features_to_instrument_sequences( + volume=np.array([15, 0]), + arpeggio=np.array([0]), + pitch=None, + hi_pitch=None, + duty_cycle=None, + loop=False, + ) + 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), diff --git a/tests/unit/sampletones_core/formats/famitracker/test_footprint.py b/tests/unit/sampletones_core/formats/famitracker/test_footprint.py index d4fecae7..7af908e3 100644 --- a/tests/unit/sampletones_core/formats/famitracker/test_footprint.py +++ b/tests/unit/sampletones_core/formats/famitracker/test_footprint.py @@ -62,7 +62,7 @@ class FootprintCase(BaseRegularTestCase): FootprintCase( features=build_features([15, 12, 9, 0], [0, 2, 4], [1, 1, 2]), loop=False, - expected=InstrumentFootprint(instrument_bytes=9, sequence_bytes=24), + expected=InstrumentFootprint(instrument_bytes=9, sequence_bytes=22), label="pulse_one_shot", ), FootprintCase( @@ -71,10 +71,16 @@ class FootprintCase(BaseRegularTestCase): expected=InstrumentFootprint(instrument_bytes=9, sequence_bytes=21), label="pulse_loop", ), + FootprintCase( + features=build_features([15, 0], [0], [0]), + loop=False, + expected=InstrumentFootprint(instrument_bytes=9, sequence_bytes=16), + label="dimensions_of_differing_lengths", + ), FootprintCase( features=build_features([15, 12, 0], [0, 1], None), loop=False, - expected=InstrumentFootprint(instrument_bytes=7, sequence_bytes=14), + expected=InstrumentFootprint(instrument_bytes=7, sequence_bytes=13), label="triangle", ), FootprintCase( diff --git a/tests/unit/sampletones_core/formats/famitracker/test_fti.py b/tests/unit/sampletones_core/formats/famitracker/test_fti.py index ec16cfc9..8503bd4b 100644 --- a/tests/unit/sampletones_core/formats/famitracker/test_fti.py +++ b/tests/unit/sampletones_core/formats/famitracker/test_fti.py @@ -19,9 +19,9 @@ GOLDEN_FTI_BYTES = ( b"FTI2.4\x01\x0f\x00\x00\x00Test Instrument\x05" b"\x01\x04\x00\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x0f\x0c\x08\x00" - b"\x01\x04\x00\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x02\xfd\xfd" + b"\x01\x03\x00\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x02\xfd" b"\x00\x00" - b"\x01\x04\x00\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x01\x01\x01" + b"\x01\x02\x00\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x01" b"\x00\x00\x00\x00\x00\x00\x00\x00" ) @@ -118,9 +118,9 @@ def parse_fti(data: bytes) -> ParsedFti: class TestWriteFtiGoldenBytes: - """Pins the byte output so a change in the writer is caught. Every populated - sequence carries the same item count, the arpeggio and duty envelopes holding - their final value through the volume envelope's trailing note-off item.""" + """Pins the byte output so a change in the writer is caught. Each populated + sequence carries the items its own envelope was written with, the shorter + arpeggio and duty envelopes ending before the volume envelope does.""" def test_output_matches_golden(self, tmp_path: Path) -> None: path = tmp_path / "golden.fti" @@ -162,7 +162,7 @@ def test_enabled_sequence_items_round_trip(self, tmp_path: Path) -> None: 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, -3] + assert parsed.sequences[1].items == [0, 2, -3] def test_missing_sequences_are_disabled(self, tmp_path: Path) -> None: path = tmp_path / "instrument.fti" From 8cbc4a11907e733dd5a6dbe6097aec0762aca2d4 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 12 Aug 2026 20:21:12 +0200 Subject: [PATCH 03/11] Added: envelopes the channel governs --- docs/formats/famitracker.md | 4 +- docs/formats/reconstructions.md | 8 +- .../services/regeneration.py | 23 ++- src/sampletones_core/exporters/exporter.py | 28 ++- src/sampletones_core/exporters/feature.py | 29 ++- src/sampletones_core/features/__init__.py | 2 + src/sampletones_core/features/spec.py | 9 + .../reconstruction/instructions.py | 10 +- .../reconstruction/reconstruction.py | 25 ++- .../services/conftest.py | 1 + .../logic/project/test_controller.py | 1 + .../services/test_regeneration.py | 9 +- .../exporters/test_exporter.py | 180 +++++++++++++++++- .../exporters/test_feature.py | 24 +++ .../formats/famitracker/test_builder.py | 1 + .../reconstruction/test_reconstruction.py | 63 +++++- 16 files changed, 391 insertions(+), 26 deletions(-) diff --git a/docs/formats/famitracker.md b/docs/formats/famitracker.md index 978fbb9e..38b22c3b 100644 --- a/docs/formats/famitracker.md +++ b/docs/formats/famitracker.md @@ -162,7 +162,9 @@ 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 one carrying a single zero: the disabled slot leaves that dimension to the channel, while a -one-item sequence sets the value once and holds it. +one-item sequence sets the value once and holds it. A dimension arrives empty when the +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 reconstruction becomes one instrument, so a sample yields one to four instruments. diff --git a/docs/formats/reconstructions.md b/docs/formats/reconstructions.md index 7b455b98..cea07b3a 100644 --- a/docs/formats/reconstructions.md +++ b/docs/formats/reconstructions.md @@ -34,7 +34,13 @@ A `.stn` file holds: measured against, chosen once when the reconstruction is built and stored with the instructions it describes. An export reads the offsets against this pitch, so editing an arpeggio moves the frames around a base that stays put (see - [FamiTracker export](famitracker.md)). + [FamiTracker export](famitracker.md)); +* **per-channel held dimensions** — the envelopes each channel leaves to the + player. An instruction states a value for every dimension of its frame, so this + 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 freshly + built reconstruction writes them all, and clearing an envelope in the + instruments panel adds that dimension here. ## Detached reconstructions diff --git a/src/sampletones_application/services/regeneration.py b/src/sampletones_application/services/regeneration.py index 7281497d..5602803d 100644 --- a/src/sampletones_application/services/regeneration.py +++ b/src/sampletones_application/services/regeneration.py @@ -12,6 +12,7 @@ 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.generators import GeneratorUnion from sampletones_core.instructions import InstructionUnion from sampletones_core.reconstructions import Reconstruction from sampletones_core.types.feature import FeatureValue @@ -100,9 +101,7 @@ def _run( exporter_class.from_features(features), ) generator = generator_class(reconstruction.config, generator_name) - audio = np.concatenate( - [generator(instruction, save=True) for instruction in instructions], # type: ignore[arg-type] - ) + audio = self._render(generator, instructions) updated = reconstruction.model_copy(deep=True) updated.update_generator_data( @@ -110,6 +109,7 @@ def _run( instructions, audio, features.initial_pitch, + features.held_features, ) self._emit( ServiceSuccess( @@ -122,3 +122,20 @@ def _run( ) except Exception as exception: # pylint: disable=broad-exception-caught self._emit(ServiceError(exception=exception)) + + @staticmethod + def _render( + generator: GeneratorUnion, + instructions: List[InstructionUnion], + ) -> np.ndarray: + """Synthesizes the frames the instructions describe, one after another. + + An instrument whose every dimension is left to the channel describes no frame, and + sounds as the silence of an empty waveform. + """ + if not instructions: + return np.zeros(0, dtype=np.float32) + + return np.concatenate( + [generator(instruction, save=True) for instruction in instructions], # type: ignore[arg-type] + ) diff --git a/src/sampletones_core/exporters/exporter.py b/src/sampletones_core/exporters/exporter.py index 798388bc..b4550217 100644 --- a/src/sampletones_core/exporters/exporter.py +++ b/src/sampletones_core/exporters/exporter.py @@ -1,9 +1,10 @@ from abc import ABC, abstractmethod -from typing import ClassVar, Dict, Final, Generic, List, Optional, Union, cast +from typing import ClassVar, Dict, Generic, Iterable, List, Optional, Union, cast import numpy as np from sampletones_core.constants.enums import FeatureKey +from sampletones_core.features import CHANNEL_FEATURE_DEFAULTS from sampletones_core.generators import GeneratorTypeUnion from sampletones_core.instructions import ( InstructionFields, @@ -15,8 +16,6 @@ from .feature import Features -EMPTY_ENVELOPE_VALUE: Final[int] = 0 - class Exporter(ABC, Generic[InstructionT]): """ @@ -38,18 +37,26 @@ def to_features( self, instructions: List[InstructionT], initial_pitch: int, + held_features: Iterable[FeatureKey], ) -> Features: """Converts an instruction sequence into its :class:`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. + Args: instructions: The channel's per-frame instructions. initial_pitch: Reference pitch the arpeggio envelope is measured against. + held_features: The dimensions the channel governs. Returns: Features: The envelope representation of the sequence. """ feature_map = self.get_feature_map(instructions, initial_pitch) - return self.from_feature_map_to_features(feature_map) + 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: @@ -115,7 +122,10 @@ def from_features(cls, features: Features) -> List[InstructionT]: Walks the envelopes frame by frame and assembles one instruction per frame. Every envelope is read relative to itself — a dimension trimmed shorter than the sequence holds its own final value over the remaining frames — so the arpeggio stays an - offset from ``initial_pitch`` for the whole sequence. + offset from ``initial_pitch`` for the whole sequence. A dimension the instrument + leaves to the channel carries no item, and every frame states the value a channel + holds for it from the start of a song, which is what the sequence sounds like played + on its own. Args: features: The envelope representation of a channel. @@ -139,7 +149,13 @@ def from_features(cls, features: Features) -> List[InstructionT]: if not attribute: continue - instruction_dictionary[attribute] = int(hold(array, index, default=EMPTY_ENVELOPE_VALUE)) + instruction_dictionary[attribute] = int( + hold( + array, + index, + default=CHANNEL_FEATURE_DEFAULTS[key], + ) + ) instructions.append(cls._features_dictionary_to_instruction(instruction_dictionary, initial_pitch)) diff --git a/src/sampletones_core/exporters/feature.py b/src/sampletones_core/exporters/feature.py index 33634cce..450de014 100644 --- a/src/sampletones_core/exporters/feature.py +++ b/src/sampletones_core/exporters/feature.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any, Dict, List, Optional, Tuple, cast +from typing import Any, Dict, Iterable, List, Optional, Tuple, cast import numpy as np from pydantic import BaseModel, ConfigDict @@ -15,9 +15,11 @@ class Features(BaseModel): 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. An optional dimension is absent when the channel does not - use it. The mapping interface (subscript, ``get``, ``keys``/``items``/``values``, - ``in``) exposes the envelopes keyed by :class:`FeatureKey`, passing over absent ones. + 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. @@ -106,3 +108,22 @@ 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 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 given dimensions' envelopes, so the channel governs them. + + Args: + feature_keys: The dimensions the instrument leaves to the channel. + """ + for feature_key in feature_keys: + self[feature_key] = np.array([], dtype=np.int8) diff --git a/src/sampletones_core/features/__init__.py b/src/sampletones_core/features/__init__.py index e2027c6f..f4d98233 100644 --- a/src/sampletones_core/features/__init__.py +++ b/src/sampletones_core/features/__init__.py @@ -1,4 +1,5 @@ from .spec import ( + CHANNEL_FEATURE_DEFAULTS, FEATURE_DIMENSION_ORDER, GENERATOR_FEATURE_RANGES, GENERATOR_KIND, @@ -9,6 +10,7 @@ ) __all__ = [ + "CHANNEL_FEATURE_DEFAULTS", "FEATURE_DIMENSION_ORDER", "GENERATOR_FEATURE_RANGES", "GENERATOR_KIND", diff --git a/src/sampletones_core/features/spec.py b/src/sampletones_core/features/spec.py index 132662b4..c9f7e9f5 100644 --- a/src/sampletones_core/features/spec.py +++ b/src/sampletones_core/features/spec.py @@ -27,6 +27,15 @@ class FeatureRange: ) +CHANNEL_FEATURE_DEFAULTS: Final[Dict[FeatureKey, int]] = { + FeatureKey.VOLUME: MAX_VOLUME, + FeatureKey.ARPEGGIO: 0, + FeatureKey.PITCH: 0, + FeatureKey.HI_PITCH: 0, + FeatureKey.DUTY_CYCLE: 0, +} + + GENERATOR_FEATURE_RANGES: Final[Dict[LibraryGeneratorName, Dict[FeatureKey, FeatureRange]]] = { LibraryGeneratorName.PULSE: { FeatureKey.VOLUME: FeatureRange(0, MAX_VOLUME), diff --git a/src/sampletones_core/reconstructions/reconstruction/instructions.py b/src/sampletones_core/reconstructions/reconstruction/instructions.py index be2663e5..51788d60 100644 --- a/src/sampletones_core/reconstructions/reconstruction/instructions.py +++ b/src/sampletones_core/reconstructions/reconstruction/instructions.py @@ -1,10 +1,10 @@ from __future__ import annotations -from typing import List +from typing import Iterable, List from pydantic import ConfigDict, Field -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import FeatureKey, GeneratorName from sampletones_core.data import DataModel from sampletones_core.instructions import InstructionData, InstructionUnion @@ -24,6 +24,10 @@ class InstructionsItem(DataModel): ..., description="Reference pitch the generator'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", + ) @classmethod def create( @@ -31,6 +35,7 @@ def create( generator_name: GeneratorName, instructions: List[InstructionUnion], initial_pitch: int, + held_features: Iterable[FeatureKey], ) -> InstructionsItem: return InstructionsItem( generator_name=generator_name, @@ -42,4 +47,5 @@ def create( for instruction in instructions ], initial_pitch=initial_pitch, + held_features=list(held_features), ) diff --git a/src/sampletones_core/reconstructions/reconstruction/reconstruction.py b/src/sampletones_core/reconstructions/reconstruction/reconstruction.py index c4c882ce..e6a46954 100644 --- a/src/sampletones_core/reconstructions/reconstruction/reconstruction.py +++ b/src/sampletones_core/reconstructions/reconstruction/reconstruction.py @@ -3,14 +3,14 @@ import struct from functools import cached_property from pathlib import Path -from typing import Any, Dict, Final, List, Mapping, Optional, Self, Sequence +from typing import Any, Dict, Final, Iterable, List, Mapping, Optional, Self, Sequence, Tuple from uuid import uuid4 import numpy as np from pydantic import ConfigDict, Field, ValidationError, field_serializer from sampletones_core.configs import Config -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import FeatureKey, GeneratorName from sampletones_core.data import DataModel, Metadata, MetadataContract from sampletones_core.exporters import ( GENERATOR_NAME_TO_EXPORTER_MAP, @@ -99,6 +99,16 @@ def initial_pitches(self) -> Dict[GeneratorName, int]: """The reference pitch each generator's arpeggio envelope is measured against.""" return {item.generator_name: item.initial_pitch for item in self.instructions_data} + @cached_property + def held_features(self) -> Dict[GeneratorName, Tuple[FeatureKey, ...]]: + """The dimensions each generator leaves to the channel. + + 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 {item.generator_name: tuple(item.held_features) for item in self.instructions_data} + @staticmethod def _get_exporter_class(instruction: InstructionUnion) -> ExporterTypeUnion: return INSTRUCTION_TO_EXPORTER_MAP[type(instruction)] @@ -144,6 +154,7 @@ def create( generator_name=generator_name, instructions=channel_instructions, initial_pitch=cls._derive_initial_pitch(generator_name, channel_instructions), + held_features=(), ) ) @@ -187,11 +198,14 @@ def update_generator_data( instructions: List[InstructionUnion], partial_approximation: np.ndarray, initial_pitch: int, + held_features: Iterable[FeatureKey], ) -> None: - """Replaces one generator's instructions, audio, and reference pitch. + """Replaces one generator'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. + measures the arpeggio against the same base the edit was made from. The held + dimensions travel with them for the same reason: the frames state a value for every + dimension, and this is what says which of them the instrument itself wrote. """ partial_approximation = np.trim_zeros(partial_approximation, trim="b") max_length = max( @@ -210,6 +224,7 @@ def update_generator_data( generator_name=generator_name, instructions=instructions, initial_pitch=initial_pitch, + held_features=held_features, ) if item.generator_name == generator_name else item @@ -317,6 +332,7 @@ def _invalidate_derived_caches(reconstruction: Reconstruction) -> None: reconstruction.__dict__.pop("approximations", None) reconstruction.__dict__.pop("instructions", None) reconstruction.__dict__.pop("initial_pitches", None) + reconstruction.__dict__.pop("held_features", None) @classmethod def load(cls, path: Pathlike, fast: bool = True) -> Reconstruction: @@ -387,6 +403,7 @@ def export(self) -> Dict[GeneratorName, Features]: feature: Features = exporter.to_features( instructions, # type: ignore[arg-type] self.initial_pitches[name], + self.held_features[name], ) features[name] = feature diff --git a/tests/integration/sampletones_application/services/conftest.py b/tests/integration/sampletones_application/services/conftest.py index 140ca412..10db4394 100644 --- a/tests/integration/sampletones_application/services/conftest.py +++ b/tests/integration/sampletones_application/services/conftest.py @@ -36,6 +36,7 @@ def pulse_features(pulse_instructions) -> Features: return PulseExporter().to_features( pulse_instructions, PulseExporter.derive_initial_pitch(pulse_instructions), + (), ) diff --git a/tests/unit/sampletones_application/logic/project/test_controller.py b/tests/unit/sampletones_application/logic/project/test_controller.py index bde1c642..54dfae4d 100644 --- a/tests/unit/sampletones_application/logic/project/test_controller.py +++ b/tests/unit/sampletones_application/logic/project/test_controller.py @@ -580,6 +580,7 @@ def test_in_place_reconstruction_edit_is_visible_through_project( new_instructions, np.zeros(64, dtype=np.float32), 72, + (), ) stored = controller.project.sample(sample.id).reconstruction diff --git a/tests/unit/sampletones_application/services/test_regeneration.py b/tests/unit/sampletones_application/services/test_regeneration.py index 33350b89..26f92df0 100644 --- a/tests/unit/sampletones_application/services/test_regeneration.py +++ b/tests/unit/sampletones_application/services/test_regeneration.py @@ -1,6 +1,6 @@ import threading from types import SimpleNamespace -from typing import Any, Callable, Dict, Final, Iterator, List, TypeAlias, cast +from typing import Any, Callable, Dict, Final, Iterator, List, Tuple, TypeAlias, cast from unittest.mock import MagicMock, patch import numpy as np @@ -26,13 +26,18 @@ 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``. + 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 diff --git a/tests/unit/sampletones_core/exporters/test_exporter.py b/tests/unit/sampletones_core/exporters/test_exporter.py index 950135da..349a4049 100644 --- a/tests/unit/sampletones_core/exporters/test_exporter.py +++ b/tests/unit/sampletones_core/exporters/test_exporter.py @@ -1,10 +1,11 @@ from dataclasses import dataclass -from typing import Any, Callable, Final, List, Sequence +from typing import Any, Callable, Final, List, Optional, Sequence, Tuple import numpy as np import pytest from sampletones_core.constants.enums import FeatureKey +from sampletones_core.constants.general import MAX_VOLUME from sampletones_core.exporters import ( ExporterTypeUnion, Features, @@ -40,6 +41,37 @@ def _read_period(instruction: Any) -> int: return period +def _read_volume(instruction: Any) -> int: + volume: int = instruction.volume + return volume + + +def _read_duty_cycle(instruction: Any) -> int: + duty_cycle: int = instruction.duty_cycle + return duty_cycle + + +def _read_short(instruction: Any) -> int: + return int(instruction.short) + + +def _features( + *, + initial_pitch: int, + volume: Tuple[int, ...], + arpeggio: Tuple[int, ...], + duty_cycle: Optional[Tuple[int, ...]], +) -> Features: + return Features( + initial_pitch=initial_pitch, + 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), + ) + + def _pulse_line(pitch: int) -> List[PulseInstruction]: return [PulseInstruction(on=True, pitch=pitch, volume=PULSE_VOLUME, duty_cycle=0) for _ in range(SOUNDING_FRAMES)] @@ -103,7 +135,11 @@ class TestCase(BaseRegularTestCase): @staticmethod def _export(test_case: TestCase, instructions: Sequence[InstructionUnion]) -> Features: - return test_case.exporter().to_features(list(instructions), test_case.expected) + return test_case.exporter().to_features( + list(instructions), + test_case.expected, + (), + ) @classmethod def _edited(cls, test_case: TestCase) -> List[InstructionUnion]: @@ -263,3 +299,143 @@ def test_audible_frames_stay_audible(self, test_case: TestCase) -> None: assert instructions[0].on is True assert instructions[-1].on is False + + +class TestChannelHeldDimensions(BaseTestSuite): + """A dimension left to the channel sounds at the value a channel holds from a song's start. + + An instruction states every dimension of its frame, so rebuilding a sequence from envelopes + that leave one out still has to state it. The value stated is the channel's own — full volume, + no arpeggio offset, the first timbre — which is what the instrument sounds like played alone. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + exporter: ExporterTypeUnion + features: Features + read_value: Callable[[Any], int] + expected: int + + test_cases = ( + TestCase( + label="pulse_volume", + exporter=PulseExporter, + features=_features( + initial_pitch=REFERENCE_PITCH, + volume=(), + arpeggio=(0, 0, 0), + duty_cycle=(1,), + ), + read_value=_read_volume, + expected=MAX_VOLUME, + ), + TestCase( + label="pulse_duty_cycle", + exporter=PulseExporter, + features=_features( + initial_pitch=REFERENCE_PITCH, + volume=(PULSE_VOLUME, PULSE_VOLUME, 0), + arpeggio=(0,), + duty_cycle=(), + ), + read_value=_read_duty_cycle, + expected=0, + ), + TestCase( + label="noise_volume", + exporter=NoiseExporter, + features=_features( + initial_pitch=REFERENCE_PERIOD, + volume=(), + arpeggio=(0, 0, 0), + duty_cycle=(0,), + ), + read_value=_read_volume, + expected=MAX_VOLUME, + ), + TestCase( + label="noise_mode", + exporter=NoiseExporter, + features=_features( + initial_pitch=REFERENCE_PERIOD, + volume=(NOISE_VOLUME, NOISE_VOLUME, 0), + arpeggio=(0,), + duty_cycle=(), + ), + read_value=_read_short, + expected=0, + ), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_every_frame_states_the_value_the_channel_holds(self, test_case: TestCase) -> None: + instructions = test_case.exporter.from_features(test_case.features) + + assert [test_case.read_value(instruction) for instruction in instructions] == [test_case.expected] * len( + instructions + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_the_written_dimensions_set_the_frame_count(self, test_case: TestCase) -> None: + instructions = test_case.exporter.from_features(test_case.features) + + assert len(instructions) == test_case.features.frame_count + + +class TestHeldDimensionRoundTrip: + """A dimension the channel governs comes back empty, telling it apart from one holding a zero.""" + + def test_a_held_dimension_comes_back_empty(self) -> None: + features = _features( + initial_pitch=REFERENCE_PITCH, + volume=(PULSE_VOLUME, PULSE_VOLUME, 0), + arpeggio=(), + duty_cycle=(1,), + ) + instructions = PulseExporter.from_features(features) + + exported = PulseExporter().to_features( + instructions, + REFERENCE_PITCH, + features.held_features, + ) + + assert exported.arpeggio.size == 0 + assert exported.held_features == (FeatureKey.ARPEGGIO,) + + def test_a_written_dimension_comes_back_with_its_items(self) -> None: + features = _features( + initial_pitch=REFERENCE_PITCH, + volume=(PULSE_VOLUME, PULSE_VOLUME, 0), + arpeggio=(), + duty_cycle=(1,), + ) + instructions = PulseExporter.from_features(features) + + exported = PulseExporter().to_features( + instructions, + REFERENCE_PITCH, + features.held_features, + ) + + assert exported.volume.tolist() == [PULSE_VOLUME, PULSE_VOLUME, 0] + assert exported.duty_cycle is not None + assert exported.duty_cycle.tolist() == [1] + + def test_an_instrument_holding_every_dimension_describes_no_frame(self) -> None: + features = _features( + initial_pitch=REFERENCE_PITCH, + volume=(), + arpeggio=(), + duty_cycle=(), + ) + + assert PulseExporter.from_features(features) == [] diff --git a/tests/unit/sampletones_core/exporters/test_feature.py b/tests/unit/sampletones_core/exporters/test_feature.py index 192f830e..ce4501e9 100644 --- a/tests/unit/sampletones_core/exporters/test_feature.py +++ b/tests/unit/sampletones_core/exporters/test_feature.py @@ -2,6 +2,7 @@ import numpy as np +from sampletones_core.constants.enums import FeatureKey from sampletones_core.exporters import Features @@ -26,3 +27,26 @@ def test_absent_dimensions_leave_the_count_to_the_others(self) -> None: def test_empty_envelopes_count_no_frames(self) -> None: assert build_features(0).frame_count == 0 + + +class TestHeldFeatures: + """The dimensions an instrument leaves to the channel, read off the envelopes.""" + + 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) + assert features.held_features == (FeatureKey.ARPEGGIO,) + + def test_a_dimension_the_channel_lacks_stays_out_of_the_listing(self) -> None: + """The triangle channel offers no duty cycle, which is a different absence.""" + 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 + assert features.held_features == (FeatureKey.VOLUME, FeatureKey.DUTY_CYCLE) diff --git a/tests/unit/sampletones_core/formats/famitracker/test_builder.py b/tests/unit/sampletones_core/formats/famitracker/test_builder.py index d5c66db3..0d04f281 100644 --- a/tests/unit/sampletones_core/formats/famitracker/test_builder.py +++ b/tests/unit/sampletones_core/formats/famitracker/test_builder.py @@ -69,6 +69,7 @@ def test_slot_keeps_its_pitch_after_an_arpeggio_edit(self, project_fixture: Proj arpeggiated, np.ones(RECONSTRUCTION_LENGTH, dtype=np.float32), LEAD_PITCH, + (), ) instruments, slots = build_instrument_table(project_fixture.project) diff --git a/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py b/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py index 2f3e9248..3260166e 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 GeneratorName +from sampletones_core.constants.enums import FeatureKey, GeneratorName from sampletones_core.data import Metadata from sampletones_core.instructions import PulseInstruction from sampletones_core.reconstructions import Reconstruction @@ -247,6 +247,7 @@ def test_export_measures_the_arpeggio_against_the_stored_reference(self) -> None arpeggiated, np.ones(_AUDIO_LENGTH, dtype=np.float32), _BASE_PITCH, + (), ) features = reconstruction.export()[GeneratorName.PULSE1] @@ -262,6 +263,7 @@ def test_update_generator_data_replaces_the_reference(self) -> None: [_pulse(_RESET_PITCH)], np.ones(_AUDIO_LENGTH, dtype=np.float32), _RESET_PITCH, + (), ) assert reconstruction.initial_pitches[GeneratorName.PULSE1] == _RESET_PITCH @@ -276,6 +278,65 @@ def test_reference_survives_a_save_load_round_trip(self, tmp_path: Path) -> None assert loaded.initial_pitches == reconstruction.initial_pitches +class TestHeldFeatures: + """The dimensions each generator 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. + """ + + def test_a_fresh_reconstruction_writes_every_dimension(self) -> None: + reconstruction = _reconstruction([_pulse(_BASE_PITCH)]) + + assert reconstruction.held_features[GeneratorName.PULSE1] == () + + def test_a_held_dimension_exports_an_empty_envelope(self) -> None: + reconstruction = _reconstruction([_pulse(_BASE_PITCH)] * 3) + + reconstruction.update_generator_data( + GeneratorName.PULSE1, + [_pulse(_BASE_PITCH)] * 3, + np.ones(_AUDIO_LENGTH, dtype=np.float32), + _BASE_PITCH, + (FeatureKey.ARPEGGIO,), + ) + + features = reconstruction.export()[GeneratorName.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, + [_pulse(_BASE_PITCH)] * 3, + np.ones(_AUDIO_LENGTH, dtype=np.float32), + _BASE_PITCH, + (FeatureKey.ARPEGGIO,), + ) + + features = reconstruction.export()[GeneratorName.PULSE1] + assert features.duty_cycle is not None + assert features.duty_cycle.size > 0 + + 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, + [_pulse(_BASE_PITCH)] * 3, + np.ones(_AUDIO_LENGTH, dtype=np.float32), + _BASE_PITCH, + (FeatureKey.ARPEGGIO, FeatureKey.DUTY_CYCLE), + ) + path = tmp_path / "held.stn" + + reconstruction.save(path) + loaded = Reconstruction.load(path) + + assert loaded.held_features == reconstruction.held_features + + class TestWithNesFrequency: def test_rebuilds_config(self, reconstruction_factory: ReconstructionFactory) -> None: reconstruction = reconstruction_factory() From b04453b17b3cd09ec7d7528dacdd2842509ea852 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 12 Aug 2026 21:25:22 +0200 Subject: [PATCH 04/11] Added: every channel present in a reconstruction --- docs/formats/famitracker.md | 4 + docs/formats/reconstructions.md | 11 +- .../coordinators/tabs/reconstruction.py | 2 +- .../logic/reconstruction/feature.py | 12 +- .../logic/reconstruction/instruments.py | 28 +-- .../logic/reconstruction/reconstruction.py | 64 +++++-- src/sampletones_application/tags/general.py | 6 + .../reconstruction/instruments/instruments.py | 49 ++++-- .../ui/panels/reconstruction/plot.py | 16 +- .../view_model/reconstruction/instruments.py | 9 +- .../reconstruction/reconstruction.py | 10 +- .../tabs.yaml} | 0 .../theme/instruments/tabs_muted.yaml | 21 +++ .../theme/panel/instrument.yaml | 3 + src/sampletones_core/exporters/feature.py | 10 ++ src/sampletones_core/exporters/slices.py | 12 +- src/sampletones_core/features/__init__.py | 6 + src/sampletones_core/features/spec.py | 25 +++ .../formats/famitracker/footprint.py | 11 +- .../reconstruction/instructions.py | 22 +++ .../reconstruction/reconstruction.py | 165 ++++++++++++------ tests/integration/assets/reconstruction.py | 8 +- .../logic/reconstruction/test_feature.py | 24 +-- .../logic/reconstruction/test_instruments.py | 16 +- .../reconstruction/test_reconstruction.py | 98 +++++++++++ .../reconstruction/test_instruments_panel.py | 82 +++++++-- .../ui/panels/reconstruction/test_plot.py | 97 ++++++++++ .../reconstruction/test_reconstruction.py | 3 +- .../sampletones_core/exporters/test_slices.py | 72 ++++++++ .../formats/famitracker/test_footprint.py | 7 +- .../reconstruction/test_reconstruction.py | 90 ++++++++++ 31 files changed, 831 insertions(+), 152 deletions(-) rename src/sampletones_config/theme/{instrument_tabs.yaml => instruments/tabs.yaml} (100%) create mode 100644 src/sampletones_config/theme/instruments/tabs_muted.yaml create mode 100644 tests/unit/sampletones_core/exporters/test_slices.py diff --git a/docs/formats/famitracker.md b/docs/formats/famitracker.md index 38b22c3b..b528b94f 100644 --- a/docs/formats/famitracker.md +++ b/docs/formats/famitracker.md @@ -168,6 +168,10 @@ instruments panel puts it in (see [Reconstructions](reconstructions.md)). **How _SampleToNES_ fills an instrument.** Each generator 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 +place in the instrument table, so the instruments an export writes are the channels +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 diff --git a/docs/formats/reconstructions.md b/docs/formats/reconstructions.md index cea07b3a..4eee98e1 100644 --- a/docs/formats/reconstructions.md +++ b/docs/formats/reconstructions.md @@ -26,10 +26,14 @@ A `.stn` file holds: * **approximation** — the rendered NES audio: the sum of every channel's output, the closest match to the original; * **per-channel approximations** — the audio each channel contributes on its own, - one waveform per enabled channel (`pulse1`, `pulse2`, `triangle`, `noise`); + one waveform per channel that sounds; * **per-channel instructions** — the instruction stream each channel plays, one [instruction](../glossary.md#instruction) per frame. This is the data a - FamiTracker export is built from; + FamiTracker export is built from. A reconstruction holds a stream for every one + of the four channels (`pulse1`, `pulse2`, `triangle`, `noise`), and a stream of + no frames is a channel standing by: it is written by no export and costs + nothing, while staying open to edit, so writing an envelope into it puts the + channel in play and clearing every envelope takes it out again; * **per-channel reference pitch** — the note each channel's arpeggio offsets are measured against, chosen once when the reconstruction is built and stored with the instructions it describes. An export reads the offsets against this pitch, @@ -42,6 +46,9 @@ A `.stn` file holds: built reconstruction writes them all, and clearing an envelope in the instruments panel adds that dimension here. +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. + ## Detached reconstructions A reconstruction normally remembers the path to its source audio. Embedding one diff --git a/src/sampletones_application/coordinators/tabs/reconstruction.py b/src/sampletones_application/coordinators/tabs/reconstruction.py index b11a114a..5053f44c 100644 --- a/src/sampletones_application/coordinators/tabs/reconstruction.py +++ b/src/sampletones_application/coordinators/tabs/reconstruction.py @@ -611,7 +611,7 @@ def _remove_directory(self, directory: Path) -> None: def update_reconstruction(self) -> None: self._reconstruction_panel_logic.update_reconstruction() - self._reconstruction_instruments_logic.refresh_footprint() + self._reconstruction_instruments_logic.refresh_view() def set_reconstruction_dimmed(self, dimmed: bool) -> None: self._reconstruction_plot_panel.set_reconstruction_dimmed(dimmed) diff --git a/src/sampletones_application/logic/reconstruction/feature.py b/src/sampletones_application/logic/reconstruction/feature.py index 430dd972..a6a653f6 100644 --- a/src/sampletones_application/logic/reconstruction/feature.py +++ b/src/sampletones_application/logic/reconstruction/feature.py @@ -12,6 +12,12 @@ @dataclass(frozen=True) class FeatureData: + """The envelopes of every channel a reconstruction holds, keyed by channel. + + A reconstruction exports one entry per channel whatever it sounds, so a subscript answers + for any of them and :attr:`Features.has_frames` says which ones play. + """ + generators: Dict[GeneratorName, Features] def __getitem__(self, generator_name: GeneratorName) -> Features: @@ -36,9 +42,3 @@ def load(cls, reconstruction: Reconstruction) -> FeatureData: generators[generator_name] = feature return cls(generators=generators) - - def get_generator_features( - self, - generator_name: GeneratorName, - ) -> Optional[Features]: - return self.generators.get(generator_name) diff --git a/src/sampletones_application/logic/reconstruction/instruments.py b/src/sampletones_application/logic/reconstruction/instruments.py index ac259d1c..aec69a88 100644 --- a/src/sampletones_application/logic/reconstruction/instruments.py +++ b/src/sampletones_application/logic/reconstruction/instruments.py @@ -47,12 +47,12 @@ def update_display(self) -> None: self.call(self.on_view_changed, self._build_view_model(generators)) self.call(self.on_feature_data_changed, generators) - def refresh_footprint(self) -> None: - """Reports the sizes the loaded envelopes occupy, leaving the displayed envelopes as they are. + def refresh_view(self) -> None: + """Reports which channels play and the sizes they occupy, leaving the displayed envelopes as they are. - A regeneration replaces what an instrument exports, so the byte figures 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. + A regeneration replaces what an instrument exports, so the byte figures and the standing-by + 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())) @@ -67,14 +67,16 @@ def _build_view_model( if generators is None: return ReconstructionInstrumentsViewModel( reconstruction_loaded=False, - available_generators=frozenset(), + playing_generators=frozenset(), footprint=None, ) - available_generators: FrozenSet[GeneratorName] = frozenset(generators.keys()) + playing_generators: FrozenSet[GeneratorName] = frozenset( + generator_name for generator_name, features in generators.items() if features.has_frames + ) return ReconstructionInstrumentsViewModel( reconstruction_loaded=True, - available_generators=available_generators, + playing_generators=playing_generators, footprint=self._build_footprint(generators), ) @@ -82,16 +84,18 @@ def _build_footprint( self, generators: Dict[GeneratorName, Features], ) -> SampleFootprintViewModel: - """Measures each channel's instrument as the size its own export writes. + """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 project — so each instrument is measured playing its envelopes once, matching what - **Export instrument...** produces. + **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( { generator_name: features_footprint(features, loop=False) for generator_name, features in generators.items() + if features.has_frames } ) @@ -214,6 +218,4 @@ def _get_features(self, generator_name: GeneratorName) -> Features: current_features = self.reconstruction_manager.current_features assert current_features is not None, "Current features should not be None" - features = current_features.get_generator_features(generator_name) - assert features is not None, f"Features for generator {generator_name} should not be None" - return features + return current_features[generator_name] diff --git a/src/sampletones_application/logic/reconstruction/reconstruction.py b/src/sampletones_application/logic/reconstruction/reconstruction.py index ab4a51d3..39e0a735 100644 --- a/src/sampletones_application/logic/reconstruction/reconstruction.py +++ b/src/sampletones_application/logic/reconstruction/reconstruction.py @@ -70,6 +70,7 @@ 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.on_view_changed: Optional[Callable[[ReconstructionViewModel], None]] = None @@ -90,18 +91,10 @@ def display_reconstruction(self) -> None: if not reconstruction_data: return - available_generators: FrozenSet[GeneratorName] = frozenset( - reconstruction_data.reconstruction.instructions.keys() - ) - self._selected_generators = list(available_generators) + self._playing_generators = frozenset(reconstruction_data.reconstruction.playing_generators) + self._selected_generators = self._in_channel_order(self._playing_generators) - reconstruction_file, original_audio = self._build_path_view_models(reconstruction_data) - view_model = ReconstructionViewModel( - reconstruction_loaded=True, - available_generators=available_generators, - reconstruction_file=reconstruction_file, - original_audio=original_audio, - ) + view_model = self._build_view_model(reconstruction_data) if not view_model.audio_source_enabled: self._current_audio_source = AudioSourceType.RECONSTRUCTION @@ -119,6 +112,9 @@ def update_reconstruction(self) -> None: if not reconstruction_data: return + self._adopt_playing_generators(frozenset(reconstruction_data.reconstruction.playing_generators)) + + self.call(self.on_view_changed, self._build_view_model(reconstruction_data)) self.call( self.on_waveform_update_changed, reconstruction_data.waveform_data(), @@ -127,8 +123,42 @@ def update_reconstruction(self) -> None: if self._current_audio_source != AudioSourceType.ORIGINAL: self._emit_audio_data() + def _adopt_playing_generators( + self, + playing_generators: FrozenSet[GeneratorName], + ) -> None: + """Carries the reader's choice of channels across an edit. + + An edit puts a channel in play or takes it out. A channel that keeps playing keeps + 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)) + + @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 _build_view_model( + self, + reconstruction_data: ReconstructionData, + ) -> ReconstructionViewModel: + 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), + 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.call(self.on_audio_data_changed, None) self.call(self.on_waveform_cleared) @@ -140,7 +170,8 @@ def close_reconstruction(self) -> None: self.on_view_changed, ReconstructionViewModel( reconstruction_loaded=False, - available_generators=frozenset(), + playing_generators=frozenset(), + selected_generators=frozenset(), reconstruction_file=empty_path, original_audio=empty_path, ), @@ -182,8 +213,7 @@ def request_export_instrument_dialog( if not reconstruction_data: raise AssertionError("Expected reconstruction data to be loaded before exporting an instrument") - feature_data = reconstruction_data.feature_data - if generator_name not in feature_data.generators: + if generator_name not in reconstruction_data.reconstruction.playing_generators: return instrument_name = self._get_instrument_name(generator_name) @@ -267,11 +297,12 @@ def handle_export_instruments_confirmed( destination: Path, tracker_format: TrackerFormat, ) -> None: - """Writes every generator slice of the loaded reconstruction to ``destination``. + """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, 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. + one keeping an instrument per file writes its slices beside it. A channel standing by + describes no frame and is written nowhere. Args: destination: The file the export was confirmed with. @@ -292,6 +323,7 @@ def handle_export_instruments_confirmed( instrument_slice_name(base_name, generator_name), ) for generator_name, feature in reconstruction_data.feature_data.generators.items() + if feature.has_frames ), nes_frequency=self._nes_frequency(), ) diff --git a/src/sampletones_application/tags/general.py b/src/sampletones_application/tags/general.py index 34749614..da52f490 100644 --- a/src/sampletones_application/tags/general.py +++ b/src/sampletones_application/tags/general.py @@ -182,6 +182,12 @@ Widget.THEME, "instrument_tabs", ) +TAG_GLOBAL_THEME_INSTRUMENT_TABS_MUTED = TagName( + Page.GLOBAL, + Panel.IMPLICIT, + Widget.THEME, + "instrument_tabs_muted", +) TAG_GLOBAL_THEME_PANEL_INSTRUMENT = TagName( Page.GLOBAL, Panel.IMPLICIT, diff --git a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py index 703e998f..c3766a7f 100644 --- a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py +++ b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py @@ -18,6 +18,7 @@ TAG_GLOBAL_THEME_INPUT_INVALID, TAG_GLOBAL_THEME_INPUT_WARNING, TAG_GLOBAL_THEME_INSTRUMENT_TABS, + TAG_GLOBAL_THEME_INSTRUMENT_TABS_MUTED, TAG_GLOBAL_THEME_PANEL_INSTRUMENT, ) from sampletones_application.tags.graphs import ( @@ -67,9 +68,8 @@ GeneratorName, LibraryGeneratorName, ) -from sampletones_core.constants.general import MAX_PERIOD, MIN_PITCH from sampletones_core.exporters import Features -from sampletones_core.features import GENERATOR_KIND, supported_features +from sampletones_core.features import GENERATOR_KIND, resting_reference, supported_features from sampletones_core.formats.famitracker.specification.sequences import ( MAX_SEQUENCE_ITEMS, ) @@ -104,6 +104,7 @@ def __init__( self.generator_plots: Dict[GeneratorName, Dict[FeatureKey, GUIBarGraph]] = {} self._pitch_steppers: Dict[GeneratorName, GUIPitchStepper] = {} + self._export_buttons: Dict[GeneratorName, 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) @@ -306,7 +307,7 @@ def _create_generator_tab(self, generator_name: GeneratorName) -> None: ): self.generator_plots[generator_name] = {} button_tag = compose_tag(TAG_RECONSTRUCTIONS_INSTRUMENTS_BUTTON_EXPORT_INSTRUMENT, tab_tag) - GUIButton( + self._export_buttons[generator_name] = GUIButton( tag=button_tag, parent=tab_tag, label=self._language_manager["reconstructions.instruments.label.export_instrument_button"], @@ -346,7 +347,7 @@ def _create_generator_content( self._create_generator_feature_displays(generator_name, window_tag) def _default_initial_pitch(self, generator_name: GeneratorName) -> int: - return MAX_PERIOD if generator_name == GeneratorName.NOISE else MIN_PITCH + return resting_reference(generator_name) def _create_generator_feature_displays( self, @@ -435,6 +436,12 @@ def update_view( self, view_model: ReconstructionInstrumentsViewModel, ) -> None: + """Shows a tab per channel, marking the ones 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. + """ 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) @@ -443,26 +450,46 @@ def update_view( for generator_name in GeneratorName.items(): tab_tag = self._get_generator_tab_tag(generator_name) - is_available = generator_name in view_model.available_generators - dpg_configure_item(tab_tag, show=is_available) + dpg_configure_item(tab_tag, show=is_loaded) + self._apply_playing_state( + generator_name, + generator_name in view_model.playing_generators, + ) + + def _apply_playing_state( + self, + generator_name: GeneratorName, + is_playing: bool, + ) -> 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, + 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)) + + export_button = self._export_buttons.get(generator_name) + if export_button is not None: + export_button.set_enabled(is_playing) def _update_sizes( self, footprint: Optional[SampleFootprintViewModel], ) -> None: - """Writes the byte figures the loaded reconstruction occupies, the sample's and each channel's.""" + """Writes the byte figures the loaded reconstruction occupies, the sample's and each channel's. + + A channel standing by is written by no export, so it reads as the nothing it costs. + """ if footprint is None: 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) - if instrument_bytes is None: - continue - dpg_set_value( self._get_instrument_size_tag(generator_name), - self._format_size(instrument_bytes), + self._format_size(instrument_bytes if instrument_bytes is not None else 0), ) def _format_size(self, byte_count: int) -> str: diff --git a/src/sampletones_application/ui/panels/reconstruction/plot.py b/src/sampletones_application/ui/panels/reconstruction/plot.py index 617d96cf..be400693 100644 --- a/src/sampletones_application/ui/panels/reconstruction/plot.py +++ b/src/sampletones_application/ui/panels/reconstruction/plot.py @@ -86,16 +86,22 @@ def create_panel(self, parent: str) -> None: self._create_tooltips() def update_view(self, view_model: ReconstructionViewModel) -> None: + """Offers a checkbox for each channel that plays, ticked where the reader keeps it on. + + 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_available = generator_name in view_model.available_generators + is_playing = generator_name in view_model.playing_generators + is_selected = generator_name in view_model.selected_generators dpg_configure_item( tag, - enabled=is_available, - default_value=is_available, + enabled=is_playing, + default_value=is_selected, ) - dpg_set_value(tag, is_available) - if is_available: + dpg_set_value(tag, is_selected) + if is_playing: ThemeRegistry.get(_GENERATOR_THEME_TAGS[generator_name]).bind_to_item(tag) else: dpg.bind_item_theme(tag, 0) diff --git a/src/sampletones_application/view_model/reconstruction/instruments.py b/src/sampletones_application/view_model/reconstruction/instruments.py index 9b3a955b..15c808fb 100644 --- a/src/sampletones_application/view_model/reconstruction/instruments.py +++ b/src/sampletones_application/view_model/reconstruction/instruments.py @@ -7,6 +7,13 @@ 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 + panel reads to mark the standing-by tabs and to offer their export. + """ + reconstruction_loaded: bool - available_generators: FrozenSet[GeneratorName] + playing_generators: FrozenSet[GeneratorName] footprint: Optional[SampleFootprintViewModel] diff --git a/src/sampletones_application/view_model/reconstruction/reconstruction.py b/src/sampletones_application/view_model/reconstruction/reconstruction.py index b2005950..7c48cf0e 100644 --- a/src/sampletones_application/view_model/reconstruction/reconstruction.py +++ b/src/sampletones_application/view_model/reconstruction/reconstruction.py @@ -33,8 +33,16 @@ class ReconstructionPathViewModel(BaseModel, frozen=True): 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 + switched on, so a channel switched off by hand stays off across an edit. + """ + reconstruction_loaded: bool - available_generators: FrozenSet[GeneratorName] + playing_generators: FrozenSet[GeneratorName] + selected_generators: FrozenSet[GeneratorName] reconstruction_file: ReconstructionPathViewModel original_audio: ReconstructionPathViewModel diff --git a/src/sampletones_config/theme/instrument_tabs.yaml b/src/sampletones_config/theme/instruments/tabs.yaml similarity index 100% rename from src/sampletones_config/theme/instrument_tabs.yaml rename to src/sampletones_config/theme/instruments/tabs.yaml diff --git a/src/sampletones_config/theme/instruments/tabs_muted.yaml b/src/sampletones_config/theme/instruments/tabs_muted.yaml new file mode 100644 index 00000000..b6d0c214 --- /dev/null +++ b/src/sampletones_config/theme/instruments/tabs_muted.yaml @@ -0,0 +1,21 @@ +name: instrument_tabs_muted +tag: global.theme.instrument_tabs_muted + +components: + - item_type: All + entries: + - type: color + key: Text + value: .text_muted + - type: color + key: Tab + value: .recess + - type: color + key: TabHovered + value: .ground/0.75 + - type: color + key: TabSelected + value: .ground + - type: color + key: TabDimmedSelected + value: .recess diff --git a/src/sampletones_config/theme/panel/instrument.yaml b/src/sampletones_config/theme/panel/instrument.yaml index 4e003dd0..d3355cdb 100644 --- a/src/sampletones_config/theme/panel/instrument.yaml +++ b/src/sampletones_config/theme/panel/instrument.yaml @@ -7,3 +7,6 @@ components: - type: color key: ChildBg value: .recess + - type: color + key: Text + value: .text diff --git a/src/sampletones_core/exporters/feature.py b/src/sampletones_core/exporters/feature.py index 450de014..bfd3e2a6 100644 --- a/src/sampletones_core/exporters/feature.py +++ b/src/sampletones_core/exporters/feature.py @@ -109,6 +109,16 @@ def frame_count(self) -> int: 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. diff --git a/src/sampletones_core/exporters/slices.py b/src/sampletones_core/exporters/slices.py index 6b87f51e..2d361869 100644 --- a/src/sampletones_core/exporters/slices.py +++ b/src/sampletones_core/exporters/slices.py @@ -59,10 +59,10 @@ def slot(self) -> InstrumentSlot: def iterate_sample_slices(project: Project) -> Iterator[SampleSlice]: """Walks every generator slice of every sample in instrument-table order. - A sample contributes one slice per channel its reconstruction covers, 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 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. Args: project: The project whose samples are exported. @@ -74,8 +74,8 @@ def iterate_sample_slices(project: Project) -> Iterator[SampleSlice]: for sample in project.samples: features_by_generator = sample.reconstruction.export() for generator in GeneratorName.items(): - features = features_by_generator.get(generator) - if features is None: + features = features_by_generator[generator] + if not features.has_frames: continue yield SampleSlice( diff --git a/src/sampletones_core/features/__init__.py b/src/sampletones_core/features/__init__.py index f4d98233..3a9e0af0 100644 --- a/src/sampletones_core/features/__init__.py +++ b/src/sampletones_core/features/__init__.py @@ -3,8 +3,11 @@ FEATURE_DIMENSION_ORDER, GENERATOR_FEATURE_RANGES, GENERATOR_KIND, + RESTING_REFERENCE_PERIOD, + RESTING_REFERENCE_PITCH, FeatureRange, feature_range, + resting_reference, supported_features, supports, ) @@ -14,8 +17,11 @@ "FEATURE_DIMENSION_ORDER", "GENERATOR_FEATURE_RANGES", "GENERATOR_KIND", + "RESTING_REFERENCE_PERIOD", + "RESTING_REFERENCE_PITCH", "FeatureRange", "feature_range", + "resting_reference", "supported_features", "supports", ] diff --git a/src/sampletones_core/features/spec.py b/src/sampletones_core/features/spec.py index c9f7e9f5..f7a76f2f 100644 --- a/src/sampletones_core/features/spec.py +++ b/src/sampletones_core/features/spec.py @@ -9,6 +9,7 @@ MAX_NOISE_MODE, MAX_PERIOD, MAX_VOLUME, + NUM_PERIODS, ) @@ -36,6 +37,10 @@ class FeatureRange: } +RESTING_REFERENCE_PITCH: Final[int] = 60 +RESTING_REFERENCE_PERIOD: Final[int] = NUM_PERIODS // 2 + + GENERATOR_FEATURE_RANGES: Final[Dict[LibraryGeneratorName, Dict[FeatureKey, FeatureRange]]] = { LibraryGeneratorName.PULSE: { FeatureKey.VOLUME: FeatureRange(0, MAX_VOLUME), @@ -62,6 +67,26 @@ class FeatureRange: } +def resting_reference(generator_name: GeneratorName) -> 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 + sounds every frame at that value. Resting mid-range puts a channel added by hand on an + audible note, and on a noise period between the extremes. + + Args: + generator_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]: + case LibraryGeneratorName.NOISE: + return RESTING_REFERENCE_PERIOD + case _: + return RESTING_REFERENCE_PITCH + + def supported_features(kind: LibraryGeneratorName) -> list[FeatureKey]: ranges = GENERATOR_FEATURE_RANGES[kind] return [feature for feature in FEATURE_DIMENSION_ORDER if feature in ranges] diff --git a/src/sampletones_core/formats/famitracker/footprint.py b/src/sampletones_core/formats/famitracker/footprint.py index ffa795ee..481ad494 100644 --- a/src/sampletones_core/formats/famitracker/footprint.py +++ b/src/sampletones_core/formats/famitracker/footprint.py @@ -100,22 +100,23 @@ def reconstruction_footprints( *, loop: bool, ) -> Dict[GeneratorName, InstrumentFootprint]: - """Measures one instrument per channel a reconstruction covers. + """Measures one instrument per channel a reconstruction plays. - A reconstruction exports one instrument for each of its one to four channels, so the result - holds an entry per covered channel and :func:`total_footprint` sums them into what the whole - sample costs. + An export writes an instrument for each channel that plays, so the result holds an entry + per playing channel and :func:`total_footprint` sums them into what the whole sample costs. + A channel standing by is written nowhere and therefore measured nowhere. Args: reconstruction: The reconstruction whose channels are measured. loop: Whether the sample carrying it loops while its note is held. Returns: - Dict[GeneratorName, InstrumentFootprint]: The footprint of each channel's instrument. + Dict[GeneratorName, 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() + if features.has_frames } diff --git a/src/sampletones_core/reconstructions/reconstruction/instructions.py b/src/sampletones_core/reconstructions/reconstruction/instructions.py index 51788d60..a71b4cf2 100644 --- a/src/sampletones_core/reconstructions/reconstruction/instructions.py +++ b/src/sampletones_core/reconstructions/reconstruction/instructions.py @@ -6,6 +6,7 @@ from sampletones_core.constants.enums import FeatureKey, GeneratorName from sampletones_core.data import DataModel +from sampletones_core.features import resting_reference from sampletones_core.instructions import InstructionData, InstructionUnion @@ -49,3 +50,24 @@ def create( initial_pitch=initial_pitch, held_features=list(held_features), ) + + @classmethod + def resting(cls, generator_name: GeneratorName) -> 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 + present and editable: it rests at the reference its first envelope will sound at, + and describing a frame is what puts it back in play. + + Args: + generator_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, + instructions=[], + initial_pitch=resting_reference(generator_name), + held_features=(), + ) diff --git a/src/sampletones_core/reconstructions/reconstruction/reconstruction.py b/src/sampletones_core/reconstructions/reconstruction/reconstruction.py index e6a46954..51e3f47a 100644 --- a/src/sampletones_core/reconstructions/reconstruction/reconstruction.py +++ b/src/sampletones_core/reconstructions/reconstruction/reconstruction.py @@ -3,7 +3,18 @@ import struct from functools import cached_property from pathlib import Path -from typing import Any, Dict, Final, Iterable, List, Mapping, Optional, Self, Sequence, Tuple +from typing import ( + Any, + Dict, + Final, + Iterable, + List, + Mapping, + Optional, + Self, + Sequence, + Tuple, +) from uuid import uuid4 import numpy as np @@ -19,6 +30,7 @@ ExporterUnion, Features, ) +from sampletones_core.features import resting_reference from sampletones_core.generators.maps import GENERATOR_CLASSES from sampletones_core.instructions import InstructionUnion from sampletones_shared.application import SAMPLETONES_RECONSTRUCTION_DATA_VERSION @@ -109,10 +121,36 @@ def held_features(self) -> Dict[GeneratorName, Tuple[FeatureKey, ...]]: """ return {item.generator_name: tuple(item.held_features) for item in self.instructions_data} + @cached_property + def playing_generators(self) -> Tuple[GeneratorName, ...]: + """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(name for name in GeneratorName.items() if self.instructions.get(name)) + @staticmethod def _get_exporter_class(instruction: InstructionUnion) -> ExporterTypeUnion: return INSTRUCTION_TO_EXPORTER_MAP[type(instruction)] + @classmethod + def _exporter_class( + cls, + generator_name: GeneratorName, + 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. + """ + if not instructions: + return GENERATOR_NAME_TO_EXPORTER_MAP[generator_name] + + return cls._get_exporter_class(instructions[0]) + @classmethod def _derive_initial_pitch( cls, @@ -122,12 +160,12 @@ def _derive_initial_pitch( """Chooses the reference pitch a channel's arpeggio envelope is measured against. The instruction type selects the exporter, matching how `export` resolves one. A - channel carrying no instructions takes the exporter its generator name pairs with, - which reports that exporter's resting reference. + channel describing no frame rests at the reference its first envelope will sound at. """ - exporter_class = ( - cls._get_exporter_class(instructions[0]) if instructions else GENERATOR_NAME_TO_EXPORTER_MAP[generator_name] - ) + if not instructions: + return resting_reference(generator_name) + + exporter_class = cls._get_exporter_class(instructions[0]) return exporter_class.derive_initial_pitch(instructions) # type: ignore[arg-type] @classmethod @@ -147,13 +185,16 @@ def create( ] instructions_data: List[InstructionsItem] = [] - for generator_name, instructions_list in instructions.items(): - channel_instructions = list(instructions_list) + for generator_name in GeneratorName.items(): + channel_instructions = list(instructions.get(generator_name, ())) instructions_data.append( InstructionsItem.create( generator_name=generator_name, instructions=channel_instructions, - initial_pitch=cls._derive_initial_pitch(generator_name, channel_instructions), + initial_pitch=cls._derive_initial_pitch( + generator_name, + channel_instructions, + ), held_features=(), ) ) @@ -206,30 +247,32 @@ def update_generator_data( measures the arpeggio against the same base the edit was made from. The held dimensions travel with them for the same reason: the frames state a value for every dimension, and this is what says which of them the instrument itself wrote. + + 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. """ partial_approximation = np.trim_zeros(partial_approximation, trim="b") + rendered = {name: audio for name, audio in self.approximations.items() if name != generator_name} + if partial_approximation.size: + rendered[generator_name] = partial_approximation + max_length = max( - len(partial_approximation), - *(len(np.trim_zeros(audio, trim="b")) for audio in self.approximations.values()), + (len(np.trim_zeros(audio, trim="b")) for audio in rendered.values()), + default=0, ) - rendered = { - name: partial_approximation if name == generator_name else audio - for name, audio in self.approximations.items() - } self.approximations_data = self._build_approximations_data(rendered, max_length) + + streams = {item.generator_name: item for item in self.instructions_data} + streams[generator_name] = InstructionsItem.create( + generator_name=generator_name, + instructions=instructions, + initial_pitch=initial_pitch, + held_features=held_features, + ) self.instructions_data = [ - ( - InstructionsItem.create( - generator_name=generator_name, - instructions=instructions, - initial_pitch=initial_pitch, - held_features=held_features, - ) - if item.generator_name == generator_name - else item - ) - for item in self.instructions_data + streams[name] if name in streams else InstructionsItem.resting(name) for name in GeneratorName.items() ] self._invalidate_derived_caches(self) self.approximation = self._sum_approximations([item.approximation for item in self.approximations_data]) @@ -268,22 +311,29 @@ def _resynthesized(self, config: Config) -> Reconstruction: """Re-renders every generator'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. Per-generator arrays are padded to a common length and summed; - the mixer weight is baked into each generator's output, so a plain sum reproduces the - stored approximation shape. Drive is left at unity to match the regeneration path. + 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 + 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(): - generator = GENERATOR_CLASSES[generator_name](config, generator_name.value) - if instructions: - rendered[generator_name] = np.concatenate( - [generator(instruction, save=True) for instruction in instructions] # type: ignore[arg-type] - ) - else: - rendered[generator_name] = np.zeros(0, dtype=np.float32) + 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) - approximations_data = self._build_approximations_data(rendered, max_length) + approximations_data = self._build_approximations_data( + rendered, + max_length, + ) approximation = self._sum_approximations([item.approximation for item in approximations_data]) retuned: Reconstruction = self.model_copy( @@ -314,16 +364,18 @@ def _build_approximations_data( rendered: Mapping[GeneratorName, np.ndarray], length: int, ) -> List[ApproximationsItem]: - """Pads each generator's audio to ``length`` and pairs it with its generator name. + """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-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. """ return [ ApproximationsItem( - generator_name=name, - approximation=pad(audio, 0, length), + generator_name=generator_name, + approximation=pad(rendered[generator_name], 0, length), ) - for name, audio in rendered.items() + for generator_name in GeneratorName.items() + if generator_name in rendered ] @staticmethod @@ -333,6 +385,7 @@ def _invalidate_derived_caches(reconstruction: Reconstruction) -> None: reconstruction.__dict__.pop("instructions", None) reconstruction.__dict__.pop("initial_pitches", None) reconstruction.__dict__.pop("held_features", None) + reconstruction.__dict__.pop("playing_generators", None) @classmethod def load(cls, path: Pathlike, fast: bool = True) -> Reconstruction: @@ -371,7 +424,10 @@ def validate_metadata(metadata: Metadata) -> None: if not isinstance(metadata, Metadata): return - RECONSTRUCTION_DATA_CONTRACT.validate(metadata, metadata.reconstruction_data_version) + RECONSTRUCTION_DATA_CONTRACT.validate( + metadata, + metadata.reconstruction_data_version, + ) def _validate_instructions( self, @@ -392,20 +448,27 @@ def _validate_instructions( ) def export(self) -> Dict[GeneratorName, Features]: - features: Dict[GeneratorName, Features] = {} - for name, instructions in self.instructions.items(): - if not instructions: - continue + """The envelopes each channel exports, one entry per channel the reconstruction holds. - exporter_class = self._get_exporter_class(instructions[0]) + 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. + """ + features: Dict[GeneratorName, Features] = {} + for name in GeneratorName.items(): + instructions = self.instructions[name] + exporter_class = self._exporter_class(name, instructions) exporter: ExporterUnion = exporter_class() - self._validate_instructions(exporter, instructions) - feature: Features = exporter.to_features( + if instructions: + self._validate_instructions(exporter, instructions) + + features[name] = exporter.to_features( instructions, # type: ignore[arg-type] self.initial_pitches[name], self.held_features[name], ) - features[name] = feature return features diff --git a/tests/integration/assets/reconstruction.py b/tests/integration/assets/reconstruction.py index 60fba8ba..662e9a07 100644 --- a/tests/integration/assets/reconstruction.py +++ b/tests/integration/assets/reconstruction.py @@ -81,11 +81,11 @@ def make_sample( expected_slices: FrozenSet[GeneratorName], loop: bool = False, ) -> Sample: - """Reconstructs ``audio`` into a `Sample`, asserting the covered channel slices.""" + """Reconstructs ``audio`` into a `Sample`, asserting the channels it plays.""" reconstruction = reconstruct_sample(audio, config, library, tmp_dir=tmp_dir, name=name) - covered = frozenset(reconstruction.instructions) - if covered != expected_slices: - raise AssertionError(f"Sample '{name}' covers {set(covered)}, expected {set(expected_slices)}") + played = frozenset(reconstruction.playing_generators) + if played != expected_slices: + raise AssertionError(f"Sample '{name}' covers {set(played)}, expected {set(expected_slices)}") return Sample(name=name, reconstruction=reconstruction, loop=loop) diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_feature.py b/tests/unit/sampletones_application/logic/reconstruction/test_feature.py index b62e8edb..96bb0d6b 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_feature.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_feature.py @@ -24,11 +24,20 @@ def feature_data(reconstruction: Reconstruction) -> FeatureData: class TestFeatureDataLoad: def test_load_creates_entry_for_each_generator( + self, + feature_data: FeatureData, + ) -> None: + assert set(feature_data.generators.keys()) == set(GeneratorName.items()) + + def test_a_channel_standing_by_carries_empty_envelopes( self, reconstruction: Reconstruction, feature_data: FeatureData, ) -> None: - assert set(feature_data.generators.keys()) == set(reconstruction.approximations.keys()) + """A channel the reconstruction leaves silent is loaded describing no frame.""" + standing_by = set(GeneratorName.items()) - set(reconstruction.playing_generators) + assert standing_by + assert all(not feature_data[generator_name].has_frames for generator_name in standing_by) def test_loaded_features_include_initial_pitch( self, @@ -39,15 +48,10 @@ def test_loaded_features_include_initial_pitch( class TestFeatureDataQueries: - def test_get_generator_features_returns_features_for_present( - self, - feature_data: FeatureData, - ) -> None: - result = feature_data.get_generator_features(GeneratorName.PULSE1) - assert isinstance(result, Features) - - def test_get_generator_features_returns_none_for_absent( + @pytest.mark.parametrize("generator_name", GeneratorName.items(), ids=lambda name: name.value) + def test_every_channel_answers_with_its_features( self, feature_data: FeatureData, + generator_name: GeneratorName, ) -> None: - assert feature_data.get_generator_features(GeneratorName.TRIANGLE) is None + assert isinstance(feature_data[generator_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 625f1348..3ab55ba9 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py @@ -87,7 +87,7 @@ def test_with_features_fires_on_feature_data_changed_with_data( instruments_logic.update_display() assert received == [feature_data.generators] - def test_with_features_exposes_available_generators( + def test_with_features_exposes_the_playing_generators( self, instruments_logic: ReconstructionInstrumentsLogic, mock_reconstruction_manager: MagicMock, @@ -97,7 +97,7 @@ def test_with_features_exposes_available_generators( received: List[ReconstructionInstrumentsViewModel] = [] instruments_logic.on_view_changed = received.append instruments_logic.update_display() - assert GeneratorName.PULSE1 in received[0].available_generators + assert GeneratorName.PULSE1 in received[0].playing_generators class TestReconstructionInstrumentsLogicFootprint: @@ -114,7 +114,7 @@ def test_no_reconstruction_carries_no_footprint( instruments_logic.update_display() assert received[0].footprint is None - def test_every_covered_channel_is_measured( + def test_every_playing_channel_is_measured( self, instruments_logic: ReconstructionInstrumentsLogic, mock_reconstruction_manager: MagicMock, @@ -127,7 +127,9 @@ def test_every_covered_channel_is_measured( instruments_logic.update_display() footprint = received[0].footprint assert footprint is not None - assert {instrument.generator for instrument in footprint.instruments} == set(feature_data.generators) + assert {instrument.generator for instrument in footprint.instruments} == { + generator_name for generator_name, features in feature_data.generators.items() if features.has_frames + } def test_the_size_is_the_one_a_one_shot_export_writes( self, @@ -144,7 +146,9 @@ 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) for features in feature_data.generators.values() + features_footprint(features, loop=False) + for features in feature_data.generators.values() + if features.has_frames ) assert footprint.total_bytes == expected.total_bytes @@ -224,7 +228,7 @@ def test_a_refresh_reports_the_view_alone( instruments_logic.on_view_changed = received.append instruments_logic.on_feature_data_changed = feature_updates.append - instruments_logic.refresh_footprint() + instruments_logic.refresh_view() assert len(received) == 1 assert received[0].footprint is not None diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py index ad0671d7..8c9f1c40 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py @@ -18,6 +18,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.instructions import TriangleInstruction from sampletones_core.paths import ( EXT_FILE_BITPHASE, EXT_FILE_INSTRUMENT, @@ -282,6 +283,103 @@ def test_update_skips_audio_when_source_is_original( callback.assert_not_called() +class TestReconstructionPanelLogicPlayingChannels: + """Which channels the waveform offers, and what an edit does to the reader's choice.""" + + @staticmethod + def _received(panel_logic: ReconstructionPanelLogic) -> List[ReconstructionViewModel]: + received: List[ReconstructionViewModel] = [] + panel_logic.on_view_changed = received.append + return received + + def test_display_offers_the_channels_that_play( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + loaded_data: ReconstructionData, + ) -> None: + mock_reconstruction_manager.current_reconstruction = loaded_data + received = self._received(panel_logic) + + panel_logic.display_reconstruction() + + assert received[0].playing_generators == frozenset({GeneratorName.PULSE1}) + assert received[0].selected_generators == frozenset({GeneratorName.PULSE1}) + + def test_an_edit_reports_the_view_again( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + loaded_data: ReconstructionData, + ) -> None: + mock_reconstruction_manager.current_reconstruction = loaded_data + panel_logic.display_reconstruction() + received = self._received(panel_logic) + + panel_logic.update_reconstruction() + + assert received[0].playing_generators == frozenset({GeneratorName.PULSE1}) + + def test_a_channel_switched_off_by_hand_survives_an_edit( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + loaded_data: ReconstructionData, + ) -> None: + mock_reconstruction_manager.current_reconstruction = loaded_data + panel_logic.display_reconstruction() + panel_logic.set_selected_generators([]) + received = self._received(panel_logic) + + panel_logic.update_reconstruction() + + assert received[0].selected_generators == frozenset() + + def test_a_channel_gaining_its_first_frame_joins_the_waveform( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + loaded_data: ReconstructionData, + ) -> None: + mock_reconstruction_manager.current_reconstruction = loaded_data + panel_logic.display_reconstruction() + loaded_data.reconstruction.update_generator_data( + GeneratorName.TRIANGLE, + [TriangleInstruction(on=True, pitch=48)], + np.ones(64, dtype=np.float32), + 48, + (), + ) + received = self._received(panel_logic) + + panel_logic.update_reconstruction() + + assert received[0].playing_generators == frozenset({GeneratorName.PULSE1, GeneratorName.TRIANGLE}) + assert received[0].selected_generators == frozenset({GeneratorName.PULSE1, GeneratorName.TRIANGLE}) + + def test_a_channel_taken_out_of_play_leaves_the_waveform( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + loaded_data: ReconstructionData, + ) -> None: + mock_reconstruction_manager.current_reconstruction = loaded_data + panel_logic.display_reconstruction() + loaded_data.reconstruction.update_generator_data( + GeneratorName.PULSE1, + [], + np.zeros(0, dtype=np.float32), + 60, + (), + ) + received = self._received(panel_logic) + + panel_logic.update_reconstruction() + + assert received[0].playing_generators == frozenset() + assert received[0].selected_generators == frozenset() + + class TestReconstructionPanelLogicClose: def test_close_fires_on_waveform_cleared( self, 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 962ee4d0..128ca0ba 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 +from typing import Dict, Final, List, cast from unittest.mock import MagicMock import pytest @@ -17,7 +17,10 @@ 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.ui.elements.button import GUIButton 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 @@ -46,7 +49,7 @@ NOT_LOADED: Final[ReconstructionInstrumentsViewModel] = ReconstructionInstrumentsViewModel( reconstruction_loaded=False, - available_generators=frozenset(), + playing_generators=frozenset(), footprint=None, ) @@ -57,7 +60,7 @@ def build_view_model( """A loaded reconstruction covering the given channels, each measured at the given size.""" return ReconstructionInstrumentsViewModel( reconstruction_loaded=True, - available_generators=frozenset(channel_bytes), + playing_generators=frozenset(channel_bytes), footprint=SampleFootprintViewModel( instruments=tuple( InstrumentSizeViewModel(generator=generator_name, total_bytes=byte_count) @@ -84,9 +87,13 @@ def registered_themes(layout_config: LayoutConfig) -> None: ) -@pytest.fixture +@pytest.fixture(autouse=True) def bound_themes(monkeypatch: pytest.MonkeyPatch) -> List[str]: - """Records the theme tags bound to items, standing in for the DPG binding.""" + """Records the theme tags bound to items, standing in for the DPG binding. + + The panel binds a theme wherever it marks an item, so every test stands in for the + binding and the ones asserting on it read the record. + """ tags: List[str] = [] monkeypatch.setattr(Theme, "bind_to_item", lambda self, item: tags.append(self.tag)) return tags @@ -290,21 +297,74 @@ def test_each_channel_states_its_own_size( } == {generator_name: f"{byte_count} B" for generator_name, byte_count in case.channel_bytes.items()} @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label) - def test_an_uncovered_channel_is_left_alone( + def test_a_channel_standing_by_costs_nothing( self, panel: GUIReconstructionInstrumentsPanel, written: Dict[str, str], shown: Dict[str, bool], case: SizeCase, ) -> None: - """A channel the reconstruction leaves out exports no instrument, so its tab holds no figure.""" + """A channel that describes no frame is written by no export, so its tab states what that costs.""" panel.update_view(build_view_model(case.channel_bytes)) - uncovered = [ - panel._get_instrument_size_tag(generator_name) + assert { + generator_name: written[panel._get_instrument_size_tag(generator_name)] for generator_name in GeneratorName.items() if generator_name not in case.channel_bytes - ] - assert [tag for tag in uncovered if tag in written] == [] + } == { + generator_name: "0 B" + for generator_name in GeneratorName.items() + if generator_name not in case.channel_bytes + } + + +class TestPlayingChannels: + """Every channel keeps a tab; a muted label and a withheld export mark the ones standing by. + + ``update_view`` marks each channel once in channel order, so the recorded bindings read as + one theme per channel. + """ + + def test_every_channel_keeps_its_tab( + self, + panel: GUIReconstructionInstrumentsPanel, + written: Dict[str, str], + shown: Dict[str, bool], + ) -> None: + panel.update_view(build_view_model({GeneratorName.PULSE1: 777})) + 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()} + + def test_a_channel_standing_by_reads_muted( + self, + panel: GUIReconstructionInstrumentsPanel, + written: Dict[str, str], + shown: Dict[str, bool], + bound_themes: List[str], + ) -> None: + panel.update_view(build_view_model({GeneratorName.PULSE1: 777})) + 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, + } + + def test_only_a_playing_channel_offers_its_export( + self, + panel: GUIReconstructionInstrumentsPanel, + written: Dict[str, str], + shown: Dict[str, bool], + ) -> None: + buttons = {generator_name: MagicMock() for generator_name in GeneratorName.items()} + panel._export_buttons.update(cast(Dict[GeneratorName, GUIButton], buttons)) + + panel.update_view(build_view_model({GeneratorName.TRIANGLE: 519})) + + 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() + } class TestSizeVisibility: 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 7d3dac57..76f4d190 100644 --- a/tests/unit/sampletones_application/ui/panels/reconstruction/test_plot.py +++ b/tests/unit/sampletones_application/ui/panels/reconstruction/test_plot.py @@ -7,6 +7,11 @@ from sampletones_application.ui.panels.reconstruction.plot import ( GUIReconstructionPlotPanel, ) +from sampletones_application.view_model.reconstruction.reconstruction import ( + ReconstructionPathState, + ReconstructionPathViewModel, + ReconstructionViewModel, +) from sampletones_core.constants.enums import GeneratorName from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase @@ -14,6 +19,17 @@ ALL_GENERATORS = frozenset(GeneratorName) +class StubTheme: + """Stands in for a registered theme, recording the items it was bound to.""" + + def __init__(self, tag: str, bindings: Dict[str, str]) -> None: + self.tag = tag + self._bindings = bindings + + def bind_to_item(self, item: str) -> None: + self._bindings[item] = self.tag + + class Harness: """The panel over its generator checkboxes, each shown or disabled as a reconstruction leaves it.""" @@ -28,22 +44,103 @@ def __init__( 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.bound_themes: Dict[str, str] = {} monkeypatch.setattr(plot_module.dpg, "get_value", self.values.__getitem__) monkeypatch.setattr(plot_module.dpg, "is_item_enabled", self.enabled.__getitem__) + monkeypatch.setattr(plot_module.dpg, "bind_item_theme", lambda item, theme: self.bound_themes.pop(item, None)) monkeypatch.setattr(plot_module, "dpg_set_value", self.values.__setitem__) + monkeypatch.setattr(plot_module, "dpg_configure_item", self._configure) + monkeypatch.setattr( + plot_module.ThemeRegistry, + "get", + lambda tag: StubTheme(tag, self.bound_themes), + ) self.panel = GUIReconstructionPlotPanel.__new__(GUIReconstructionPlotPanel) self.panel.on_generators_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 offered(self) -> FrozenSet[GeneratorName]: + return frozenset(generator for generator in GeneratorName if self.enabled[self._tag(generator)]) + def selected(self) -> FrozenSet[GeneratorName]: return frozenset(generator for generator in GeneratorName if self.values[self._tag(generator)]) +def _view_model( + playing: FrozenSet[GeneratorName], + selected: FrozenSet[GeneratorName], +) -> ReconstructionViewModel: + empty_path = ReconstructionPathViewModel(state=ReconstructionPathState.EMPTY, path="") + return ReconstructionViewModel( + reconstruction_loaded=True, + playing_generators=playing, + selected_generators=selected, + reconstruction_file=empty_path, + original_audio=empty_path, + ) + + +class TestGeneratorCheckboxes: + """The checkboxes offer the channels that play and tick the ones the reader keeps on.""" + + def test_a_channel_that_plays_is_offered( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + harness = Harness(selected=frozenset(), available=frozenset(), monkeypatch=monkeypatch) + playing = frozenset({GeneratorName.PULSE1, GeneratorName.NOISE}) + + harness.panel.update_view(_view_model(playing, playing)) + + assert harness.offered() == playing + assert harness.selected() == playing + + def test_a_channel_switched_off_by_hand_stays_off( + self, + 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.panel.update_view(_view_model(playing, frozenset({GeneratorName.NOISE}))) + + assert harness.offered() == playing + assert harness.selected() == frozenset({GeneratorName.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.panel.update_view(_view_model(playing, playing)) + + assert harness.selected() == playing + assert GeneratorName.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}) + + harness.panel.update_view(_view_model(playing, playing)) + + assert set(harness.bound_themes) == {Harness._tag(GeneratorName.TRIANGLE)} + + class TestToggleGenerator(BaseTestSuite): """The key a channel answers to switches its slice in and out of the waveform.""" 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 e44326b1..0a07b786 100644 --- a/tests/unit/sampletones_application/view_model/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_application/view_model/reconstruction/test_reconstruction.py @@ -71,7 +71,8 @@ def test_enablement_follows_original_audio_state( ) -> None: view_model = ReconstructionViewModel( reconstruction_loaded=case.reconstruction_loaded, - available_generators=frozenset(), + playing_generators=frozenset(), + selected_generators=frozenset(), reconstruction_file=ReconstructionPathViewModel(state=ReconstructionPathState.EMPTY, path=""), original_audio=ReconstructionPathViewModel(state=case.original_audio_state, path=""), ) diff --git a/tests/unit/sampletones_core/exporters/test_slices.py b/tests/unit/sampletones_core/exporters/test_slices.py new file mode 100644 index 00000000..856bb3bd --- /dev/null +++ b/tests/unit/sampletones_core/exporters/test_slices.py @@ -0,0 +1,72 @@ +from typing import List, Sequence + +import numpy as np + +from sampletones_core.constants.enums import FeatureKey, GeneratorName +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.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) + + project = Project.create(title="Slices", author="Tester", settings=ProjectSettings()) + project.samples = collection + return project + + +def _sample(name: str, generators: Sequence[GeneratorName]) -> Sample: + return Sample(name=name, reconstruction=sample_reconstruction(list(generators))) + + +class TestSampleSlices: + """The walk numbers the instruments a module writes, so it visits the channels that play. + + 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 test_a_sample_contributes_one_slice_per_playing_channel(self) -> None: + project = _project([_sample("lead", [GeneratorName.PULSE1, GeneratorName.NOISE])]) + + slices = list(iterate_sample_slices(project)) + + assert [sample_slice.generator for sample_slice in slices] == [ + GeneratorName.PULSE1, + GeneratorName.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, + [], + np.zeros(0, dtype=np.float32), + sample.reconstruction.initial_pitches[GeneratorName.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), + ] + + def test_slices_are_numbered_across_the_samples_in_order(self) -> None: + project = _project( + [ + _sample("lead", [GeneratorName.PULSE1]), + _sample("pad", [GeneratorName.TRIANGLE, GeneratorName.NOISE]), + ] + ) + + indices: List[int] = [sample_slice.index for sample_slice in iterate_sample_slices(project)] + + assert indices == [0, 1, 2] diff --git a/tests/unit/sampletones_core/formats/famitracker/test_footprint.py b/tests/unit/sampletones_core/formats/famitracker/test_footprint.py index 7af908e3..917b02a0 100644 --- a/tests/unit/sampletones_core/formats/famitracker/test_footprint.py +++ b/tests/unit/sampletones_core/formats/famitracker/test_footprint.py @@ -158,7 +158,8 @@ def test_no_instruments_cost_nothing(self) -> None: class TestReconstructionFootprints: - def test_one_entry_per_covered_channel(self) -> None: + 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} @@ -176,7 +177,9 @@ 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() + generator_name: features_footprint(feature, loop=loop) + for generator_name, feature in features.items() + if feature.has_frames } def test_looping_costs_the_shortest_dimensions_length(self) -> None: diff --git a/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py b/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py index 3260166e..e9285f7e 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py @@ -9,6 +9,7 @@ from sampletones_core.configs import Config from sampletones_core.constants.enums import FeatureKey, GeneratorName 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_shared.application import ( @@ -337,6 +338,95 @@ def test_held_dimensions_survive_a_save_load_round_trip(self, tmp_path: Path) -> assert loaded.held_features == reconstruction.held_features +class TestChannelSet: + """A reconstruction holds every channel, so one that stands by stays editable. + + An instruction stream describing no frame is what a channel standing by looks like: it + exports empty envelopes, costs nothing, and gaining a frame is what puts it in play. + """ + + 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,) + + 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) + + def test_a_channel_standing_by_exports_empty_envelopes(self) -> None: + features = _reconstruction([_pulse(_BASE_PITCH)]).export()[GeneratorName.PULSE2] + + assert not features.has_frames + assert features.volume.size == 0 + assert features.arpeggio.size == 0 + + def test_a_channel_standing_by_renders_no_audio(self) -> None: + reconstruction = _reconstruction([_pulse(_BASE_PITCH)]) + + assert GeneratorName.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, + [], + 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 + + def test_a_frame_puts_a_channel_standing_by_into_play(self) -> None: + reconstruction = _reconstruction([_pulse(_BASE_PITCH)]) + + reconstruction.update_generator_data( + GeneratorName.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 + + def test_a_reconstruction_of_channels_standing_by_stays_valid(self) -> None: + reconstruction = _reconstruction([_pulse(_BASE_PITCH)]) + + reconstruction.update_generator_data( + GeneratorName.PULSE1, + [], + np.zeros(0, dtype=np.float32), + _BASE_PITCH, + (), + ) + + assert reconstruction.approximations == {} + assert reconstruction.approximation.size == 0 + + def test_the_channel_set_survives_a_save_load_round_trip(self, tmp_path: Path) -> None: + reconstruction = _reconstruction([_pulse(_BASE_PITCH)]) + path = tmp_path / "channels.stn" + + reconstruction.save(path) + loaded = Reconstruction.load(path) + + assert set(loaded.instructions) == set(GeneratorName.items()) + assert loaded.playing_generators == reconstruction.playing_generators + assert loaded.initial_pitches == reconstruction.initial_pitches + + class TestWithNesFrequency: def test_rebuilds_config(self, reconstruction_factory: ReconstructionFactory) -> None: reconstruction = reconstruction_factory() From b7204c7e10c8f1657b46e4122bd57aa5ef6e9061 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 12 Aug 2026 21:59:59 +0200 Subject: [PATCH 05/11] Added: channel-held envelope values in playback --- docs/development/playback.md | 18 ++ docs/formats/reconstructions.md | 3 +- .../playback/synthesizer/__init__.py | 2 + .../sequencer/playback/synthesizer/state.py | 14 +- .../playback/synthesizer/synthesizer.py | 8 +- .../sequencer/playback/synthesizer/voice.py | 84 ++++++++ src/sampletones_core/exporters/exporter.py | 58 +++++ .../reconstruction/reconstruction.py | 34 ++- .../logic/sequencer/playback/conftest.py | 23 +- .../sequencer/playback/test_synthesizer.py | 109 +++++++++- .../logic/sequencer/playback/test_voice.py | 199 ++++++++++++++++++ .../exporters/test_exporter.py | 110 +++++++++- .../reconstruction/test_reconstruction.py | 35 +++ 13 files changed, 677 insertions(+), 20 deletions(-) create mode 100644 src/sampletones_application/logic/sequencer/playback/synthesizer/voice.py create mode 100644 tests/unit/sampletones_application/logic/sequencer/playback/test_voice.py diff --git a/docs/development/playback.md b/docs/development/playback.md index 4f33ace3..6446cd8b 100644 --- a/docs/development/playback.md +++ b/docs/development/playback.md @@ -162,6 +162,22 @@ sequencer distinguishes a history restore from a document transition. And the mu listening session, so opening, creating, or closing a document starts a fresh one with every channel audible. +## What the channel holds + +A sample states every dimension of every frame, and its reconstruction names which of those +dimensions the instrument itself wrote. The rest are the channel's: each channel carries a value per +dimension — volume, arpeggio, timbre — and an instrument leaving one empty sounds it at the value the +channel holds. That is what clearing an envelope in the instruments panel means once the sample is +played in a song, and it is the same rule a FamiTracker instrument follows with a sequence left out. + +The value moves as the song plays. Every frame an instrument writes hands its value to the channel, +so the channel keeps the last one written and an instrument that leaves the dimension empty picks it +up. A silent frame states its level alone, leaving pitch and timbre where the channel holds them. + +A pass through the song begins on the values a channel holds from the start — full volume, no +arpeggio offset, the first timbre — so starting the song and looping back to its first row both +sound the same. Seeking within a running song keeps the values, since the channel has reached them. + ## Rendering the song to a file A render writes the whole song to an audio file through the kernel that plays it. `RowSynthesizer` @@ -231,6 +247,8 @@ terminating would reclaim. | Where the playhead stands, and both grids' marks for it | `SequencerTabCoordinator` (`coordinators/tabs/sequencer.py`) | | Marking and revealing the sounding row in the tracker | `GUISequencerTrackerPanel` (`ui/panels/sequencer/tracker.py`) | | Row mixing, and the mask it pulls while rendering | `RowSynthesizer` (`logic/sequencer/playback/synthesizer/`) | +| Filling in the dimensions a channel governs, frame by frame | `SampleVoice` (`logic/sequencer/playback/synthesizer/voice.py`) | +| The values a channel holds between frames | `ChannelState` (`logic/sequencer/playback/synthesizer/state.py`) | | The channel generators and the rates they are built at | `ChannelBank` (`logic/sequencer/playback/synthesizer/bank.py`) | | How long each row of a pattern lasts | `Groove` (`sampletones_core/timing/`), indexed by row while rendering | | How many samples one of that row's ticks spans | `TickClock` (`sampletones_core/timing/`), followed by `EngineRates` | diff --git a/docs/formats/reconstructions.md b/docs/formats/reconstructions.md index 4eee98e1..d0fb3174 100644 --- a/docs/formats/reconstructions.md +++ b/docs/formats/reconstructions.md @@ -47,7 +47,8 @@ A `.stn` file holds: instruments panel adds that dimension here. 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. +written into it sounds on a mid-range note. A file naming a stream for the channels +it plays alone reads as the whole four, with the rest coming back standing by. ## Detached reconstructions diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/__init__.py b/src/sampletones_application/logic/sequencer/playback/synthesizer/__init__.py index 40b55f8c..a60f7ba8 100644 --- a/src/sampletones_application/logic/sequencer/playback/synthesizer/__init__.py +++ b/src/sampletones_application/logic/sequencer/playback/synthesizer/__init__.py @@ -6,6 +6,7 @@ from .state import ChannelState from .synthesizer import RowSynthesizer from .timing import SongTiming +from .voice import SampleVoice __all__ = [ "ChannelBank", @@ -13,6 +14,7 @@ "EngineRates", "RowFrames", "RowSynthesizer", + "SampleVoice", "SongLength", "SongTiming", "apply_modifiers", diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/state.py b/src/sampletones_application/logic/sequencer/playback/synthesizer/state.py index ca2974a8..5af877fe 100644 --- a/src/sampletones_application/logic/sequencer/playback/synthesizer/state.py +++ b/src/sampletones_application/logic/sequencer/playback/synthesizer/state.py @@ -1,7 +1,9 @@ from dataclasses import dataclass, field -from typing import Optional +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 ..protocol import ChannelGeneratorProtocol @@ -15,12 +17,17 @@ class ChannelState: 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: 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. """ generator: ChannelGeneratorProtocol @@ -28,10 +35,14 @@ class ChannelState: 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. + The generator is kept, since it is built from the rates in force rather than from anything a song reaches. """ @@ -39,3 +50,4 @@ def reset(self) -> None: self.tick_index = 0 self.transpose = 0 self.volume = MAX_VOLUME + self.feature_values = CHANNEL_FEATURE_DEFAULTS.copy() diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/synthesizer.py b/src/sampletones_application/logic/sequencer/playback/synthesizer/synthesizer.py index 3035821e..2e1de2d8 100644 --- a/src/sampletones_application/logic/sequencer/playback/synthesizer/synthesizer.py +++ b/src/sampletones_application/logic/sequencer/playback/synthesizer/synthesizer.py @@ -23,6 +23,7 @@ from .rates import EngineRates from .state import ChannelState from .timing import SongTiming +from .voice import SampleVoice class RowSynthesizer: @@ -261,10 +262,11 @@ def _synthesize_ticks( if sample is None: return silence(frames.total) - instructions = sample.reconstruction.instructions.get(generator_name) + instructions = sample.reconstruction.instructions[generator_name] if not instructions: return silence(frames.total) + voice = SampleVoice.read(sample.reconstruction, generator_name) output = silence(frames.total) silence_frame = silence(frames.longest) @@ -275,6 +277,7 @@ def _synthesize_ticks( silence_frame[:frame_length], sample.loop, frame_length, + voice, ) output[frames.bounds[tick] : frames.bounds[tick + 1]] = frame state.tick_index += 1 @@ -288,6 +291,7 @@ def _synthesize_tick( silence_frame: np.ndarray, loop: bool, frame_length: int, + voice: SampleVoice, ) -> np.ndarray: if loop: instruction = instructions[state.tick_index % len(instructions)] @@ -299,7 +303,7 @@ def _synthesize_tick( state.generator.frame_length = frame_length return state.generator( apply_modifiers( - instruction, + voice.sound(instruction, state.feature_values), state.transpose, state.volume, ), diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/voice.py b/src/sampletones_application/logic/sequencer/playback/synthesizer/voice.py new file mode 100644 index 00000000..51b34832 --- /dev/null +++ b/src/sampletones_application/logic/sequencer/playback/synthesizer/voice.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +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.instructions import InstructionUnion +from sampletones_core.reconstructions import Reconstruction + + +@dataclass(frozen=True) +class SampleVoice: + """How one channel reads a sample'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. + + 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. + """ + + exporter: ExporterTypeUnion + initial_pitch: int + held_features: Tuple[FeatureKey, ...] + + @classmethod + def read( + cls, + reconstruction: Reconstruction, + generator_name: GeneratorName, + ) -> SampleVoice: + """The voice one channel of ``reconstruction`` is played through. + + Args: + reconstruction: The sample's reconstruction. + generator_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], + ) + + def sound( + self, + instruction: InstructionUnion, + feature_values: Dict[FeatureKey, int], + ) -> InstructionUnion: + """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. + + Args: + instruction: The frame as the sample holds it. + feature_values: The values the channel holds, updated with what the instrument 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, + ) + for feature_key, value in stated.items(): + if feature_key not in self.held_features: + feature_values[feature_key] = value + + sounded: InstructionUnion = self.exporter.instruction_from_values( + feature_values, + self.initial_pitch, + ) + return sounded diff --git a/src/sampletones_core/exporters/exporter.py b/src/sampletones_core/exporters/exporter.py index b4550217..154e0d61 100644 --- a/src/sampletones_core/exporters/exporter.py +++ b/src/sampletones_core/exporters/exporter.py @@ -161,6 +161,64 @@ def from_features(cls, features: Features) -> List[InstructionT]: return instructions + @classmethod + def feature_values( + cls, + instruction: InstructionT, + initial_pitch: int, + ) -> Dict[FeatureKey, int]: + """The envelope values one frame states. + + A frame that sounds states every dimension the channel reads, each in the terms its + envelope is written in. A silent frame states its level alone, leaving the rest to the + channel, which is how a sequence holds its pitch and timbre across a rest. + + Reading the frame as a sequence of one is what keeps this the same reading `to_features` + gives it, so a frame played in a song carries the values its envelopes show. + + Args: + instruction: The frame to read. + initial_pitch: Reference pitch the arpeggio value is measured against. + + Returns: + Dict[FeatureKey, int]: The value the frame states for each dimension it names. + """ + 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 + } + + @classmethod + def instruction_from_values( + cls, + values: Dict[FeatureKey, int], + initial_pitch: int, + ) -> InstructionT: + """The frame a row of envelope values describes. + + This is the single-frame form of `from_features`: values arrive in envelope terms and + come back as the instruction a generator sounds, with the arpeggio measured against + ``initial_pitch``. Dimensions this channel reads nothing from are passed over, so one + set of values serves every channel. + + Args: + values: The value each dimension carries for one frame. + initial_pitch: Reference pitch the arpeggio value is measured against. + + Returns: + InstructionT: The frame those values describe. + """ + dictionary: Dict[str, Union[bool, int]] = {} + for key, value in values.items(): + attribute = cls._remap_feature_key(key) + if attribute is not None: + dictionary[attribute] = value + + return cls._features_dictionary_to_instruction(dictionary, initial_pitch) + @classmethod @abstractmethod def _features_dictionary_to_instruction( diff --git a/src/sampletones_core/reconstructions/reconstruction/reconstruction.py b/src/sampletones_core/reconstructions/reconstruction/reconstruction.py index 51e3f47a..4655c608 100644 --- a/src/sampletones_core/reconstructions/reconstruction/reconstruction.py +++ b/src/sampletones_core/reconstructions/reconstruction/reconstruction.py @@ -99,17 +99,32 @@ class Reconstruction(DataModel): def approximations(self) -> Dict[GeneratorName, np.ndarray]: return {item.generator_name: item.approximation for item in self.approximations_data} + @cached_property + def streams(self) -> Dict[GeneratorName, 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 + for keeps it, and one it names none for rests, which is what a channel standing by + 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} + return { + generator_name: stored.get(generator_name, InstructionsItem.resting(generator_name)) + for generator_name in GeneratorName.items() + } + @cached_property def instructions(self) -> Dict[GeneratorName, List[InstructionUnion]]: return { - item.generator_name: [instruction.instruction for instruction in item.instructions] - for item in self.instructions_data + generator_name: [instruction.instruction for instruction in item.instructions] + for generator_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 {item.generator_name: item.initial_pitch for item in self.instructions_data} + return {generator_name: item.initial_pitch for generator_name, item in self.streams.items()} @cached_property def held_features(self) -> Dict[GeneratorName, Tuple[FeatureKey, ...]]: @@ -119,7 +134,7 @@ def held_features(self) -> Dict[GeneratorName, Tuple[FeatureKey, ...]]: 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 {item.generator_name: tuple(item.held_features) for item in self.instructions_data} + return {generator_name: tuple(item.held_features) for generator_name, item in self.streams.items()} @cached_property def playing_generators(self) -> Tuple[GeneratorName, ...]: @@ -129,7 +144,7 @@ def playing_generators(self) -> Tuple[GeneratorName, ...]: play: the rest stand by, exporting nothing and costing nothing, while describing a frame is what puts one in play. """ - return tuple(name for name in GeneratorName.items() if self.instructions.get(name)) + return tuple(generator_name for generator_name, item in self.streams.items() if item.instructions) @staticmethod def _get_exporter_class(instruction: InstructionUnion) -> ExporterTypeUnion: @@ -264,16 +279,14 @@ def update_generator_data( self.approximations_data = self._build_approximations_data(rendered, max_length) - streams = {item.generator_name: item for item in self.instructions_data} + streams = dict(self.streams) streams[generator_name] = InstructionsItem.create( generator_name=generator_name, instructions=instructions, initial_pitch=initial_pitch, held_features=held_features, ) - self.instructions_data = [ - streams[name] if name in streams else InstructionsItem.resting(name) for name in GeneratorName.items() - ] + 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]) @@ -281,7 +294,7 @@ def get_generator_instructions( self, generator_name: GeneratorName, ) -> List[InstructionUnion]: - return self.instructions.get(generator_name, []) + return self.instructions[generator_name] def detach_source(self) -> None: """Drops the local source-audio location so the reconstruction becomes self-contained. @@ -382,6 +395,7 @@ def _build_approximations_data( def _invalidate_derived_caches(reconstruction: Reconstruction) -> None: """Drops the memoized per-generator 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) diff --git a/tests/unit/sampletones_application/logic/sequencer/playback/conftest.py b/tests/unit/sampletones_application/logic/sequencer/playback/conftest.py index 5548b6be..7d65e04f 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 +from typing import Callable, FrozenSet, Iterable import numpy as np import pytest @@ -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 GeneratorName +from sampletones_core.constants.enums import FeatureKey, GeneratorName from sampletones_core.instructions import ( NoiseInstruction, PulseInstruction, @@ -52,10 +52,15 @@ def make_pulse_reconstruction( pitch: int = 60, volume: int = 15, count: int = 1, + held_features: Iterable[FeatureKey] = (), ) -> Reconstruction: - """Single-generator reconstruction with ``count`` identical PulseInstructions.""" + """Single-generator 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 - return Reconstruction.create( + reconstruction = Reconstruction.create( approximation=np.zeros(64, dtype=np.float32), approximations={GeneratorName.PULSE1: np.zeros(64, dtype=np.float32)}, instructions={GeneratorName.PULSE1: instructions}, @@ -63,6 +68,16 @@ def make_pulse_reconstruction( coefficient=1.0, audio_filepath=Path("/dev/null"), ) + if held_features: + reconstruction.update_generator_data( + GeneratorName.PULSE1, + list(instructions), + np.zeros(64, dtype=np.float32), + reconstruction.initial_pitches[GeneratorName.PULSE1], + held_features, + ) + + return reconstruction def make_triangle_reconstruction( 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 cba1f808..dbb76e04 100644 --- a/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py +++ b/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py @@ -2,6 +2,7 @@ from typing import Dict, Final, FrozenSet, List, Optional, Tuple import numpy as np +import pytest from sampletones_application.constants.playback import ( MAX_TICKS_PER_ROW, @@ -12,8 +13,10 @@ 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 GeneratorName +from sampletones_core.constants.enums import FeatureKey, GeneratorName from sampletones_core.constants.general import MAX_VOLUME +from sampletones_core.features import CHANNEL_FEATURE_DEFAULTS +from sampletones_core.reconstructions import Reconstruction from sampletones_core.timing import Metre, RowRate, calculate_groove from tests.suite.scenario import BaseTestScenario, ScenarioStep from tests.unit.sampletones_application.logic.sequencer.playback.conftest import ( @@ -27,6 +30,8 @@ ) SAMPLE_RATE: Final[int] = DEFAULT_SAMPLE_RATE +SUSTAINED_FRAMES: Final[int] = 64 +QUIET_VOLUME: Final[int] = 3 class MaskProvider: @@ -848,3 +853,105 @@ def test_frequency_change_between_rows_takes_effect_without_restart(self) -> Non assert pulse_state.generator.frame_length == round(SAMPLE_RATE / 30) assert pulse_state.sample_id is not None + + +class TestChannelHeldValues: + """A dimension an instrument leaves to the channel sounds at the value the channel holds. + + The channel carries that value from the start of a song, taking up a new one wherever an + instrument writes it, so an instrument with an empty volume envelope plays at whatever the + one before it left behind. + """ + + @staticmethod + def _place( + context: SynthesizerContext, + reconstruction: Reconstruction, + *, + row_index: int, + name: str, + ) -> None: + sample = add_sample(_controller(context), reconstruction, name=name) + place_row( + _controller(context), + generator=GeneratorName.PULSE1, + row_index=row_index, + sample_id=sample.id, + ) + + @staticmethod + def _peak(audio: np.ndarray) -> float: + return float(np.max(np.abs(audio))) + + def test_the_channel_takes_up_the_level_its_instrument_writes(self) -> None: + context = _make_context() + self._place( + context, + make_pulse_reconstruction(volume=QUIET_VOLUME, count=SUSTAINED_FRAMES), + row_index=0, + name="writes", + ) + + _render(context) + + assert _state(context).feature_values[FeatureKey.VOLUME] == QUIET_VOLUME + + def test_a_sample_holding_its_level_sounds_at_the_channels(self) -> None: + context = _make_context() + self._place( + context, + make_pulse_reconstruction(volume=QUIET_VOLUME, count=SUSTAINED_FRAMES), + row_index=0, + name="writes", + ) + self._place( + context, + make_pulse_reconstruction( + volume=MAX_VOLUME, + count=SUSTAINED_FRAMES, + held_features=(FeatureKey.VOLUME,), + ), + row_index=1, + name="holds", + ) + + written = _render(context) + held = _render(context) + + assert self._peak(held) == pytest.approx(self._peak(written)) + + def test_a_song_starts_a_held_level_at_full_volume(self) -> None: + holding = _make_context() + self._place( + holding, + make_pulse_reconstruction( + volume=QUIET_VOLUME, + count=SUSTAINED_FRAMES, + held_features=(FeatureKey.VOLUME,), + ), + row_index=0, + name="holds", + ) + writing = _make_context() + self._place( + writing, + make_pulse_reconstruction(volume=MAX_VOLUME, count=SUSTAINED_FRAMES), + row_index=0, + name="writes", + ) + + assert self._peak(_render(holding)) == pytest.approx(self._peak(_render(writing))) + + def test_a_reset_returns_every_channel_to_the_values_a_song_starts_on(self) -> None: + context = _make_context() + self._place( + context, + make_pulse_reconstruction(volume=QUIET_VOLUME, count=SUSTAINED_FRAMES), + row_index=0, + name="writes", + ) + _render(context) + + context.synthesizer.reset() + + assert _state(context).feature_values == CHANNEL_FEATURE_DEFAULTS diff --git a/tests/unit/sampletones_application/logic/sequencer/playback/test_voice.py b/tests/unit/sampletones_application/logic/sequencer/playback/test_voice.py new file mode 100644 index 00000000..be4cb06b --- /dev/null +++ b/tests/unit/sampletones_application/logic/sequencer/playback/test_voice.py @@ -0,0 +1,199 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Final, Iterable, List, Sequence + +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 FeatureKey, GeneratorName +from sampletones_core.constants.general import MAX_VOLUME +from sampletones_core.features import CHANNEL_FEATURE_DEFAULTS +from sampletones_core.instructions import ( + InstructionUnion, + NoiseInstruction, + PulseInstruction, + TriangleInstruction, +) +from sampletones_core.reconstructions import Reconstruction +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + +AUDIO_LENGTH: Final[int] = 64 +REFERENCE_PITCH: Final[int] = 60 +REFERENCE_PERIOD: Final[int] = 4 +SAMPLE_VOLUME: Final[int] = 9 +CHANNEL_VOLUME: Final[int] = 4 +DUTY_CYCLE: Final[int] = 2 + + +def _reconstruction( + generator_name: GeneratorName, + 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)}, + config=Config(), + coefficient=1.0, + audio_filepath=Path("/dev/null"), + ) + reconstruction.update_generator_data( + generator_name, + list(instructions), + np.ones(AUDIO_LENGTH, dtype=np.float32), + reconstruction.initial_pitches[generator_name], + held_features, + ) + return reconstruction + + +def _voice( + generator_name: GeneratorName, + instructions: Sequence[InstructionUnion], + held_features: Iterable[FeatureKey], +) -> SampleVoice: + return SampleVoice.read(_reconstruction(generator_name, instructions, held_features), generator_name) + + +def _channel_values() -> Dict[FeatureKey, int]: + return CHANNEL_FEATURE_DEFAULTS.copy() + + +class TestAFrameSoundsAsTheInstrumentWroteIt(BaseTestSuite): + """An instrument writing every dimension sounds its frames exactly as it holds them.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + generator_name: GeneratorName + instructions: List[InstructionUnion] + + test_cases = ( + TestCase( + label="pulse", + generator_name=GeneratorName.PULSE1, + instructions=[ + PulseInstruction( + on=True, + pitch=REFERENCE_PITCH, + volume=SAMPLE_VOLUME, + duty_cycle=DUTY_CYCLE, + ) + ], + ), + TestCase( + label="triangle", + generator_name=GeneratorName.TRIANGLE, + instructions=[TriangleInstruction(on=True, pitch=REFERENCE_PITCH)], + ), + TestCase( + label="noise", + generator_name=GeneratorName.NOISE, + instructions=[ + NoiseInstruction( + on=True, + period=REFERENCE_PERIOD, + volume=SAMPLE_VOLUME, + short=True, + ) + ], + ), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + 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, ()) + + assert voice.sound(test_case.instructions[0], _channel_values()) == test_case.instructions[0] + + @pytest.mark.parametrize( + "test_case", + test_cases, + 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, ()) + values = _channel_values() + + voice.sound(test_case.instructions[0], values) + + assert values[FeatureKey.ARPEGGIO] == 0 + assert values[FeatureKey.VOLUME] == (MAX_VOLUME if test_case.label == "triangle" else SAMPLE_VOLUME) + + +class TestAHeldDimensionSoundsAtTheChannelsValue: + """A dimension the instrument leaves empty is the channel's, so it sounds at the value it holds.""" + + _INSTRUCTION = PulseInstruction( + on=True, + pitch=REFERENCE_PITCH, + volume=SAMPLE_VOLUME, + duty_cycle=DUTY_CYCLE, + ) + + def test_the_channels_level_carries_over_the_frame(self) -> None: + voice = _voice(GeneratorName.PULSE1, [self._INSTRUCTION], (FeatureKey.VOLUME,)) + values = _channel_values() + values[FeatureKey.VOLUME] = CHANNEL_VOLUME + + assert voice.sound(self._INSTRUCTION, values).volume == CHANNEL_VOLUME + + def test_a_held_dimension_leaves_the_channels_value_where_it_stands(self) -> None: + voice = _voice(GeneratorName.PULSE1, [self._INSTRUCTION], (FeatureKey.VOLUME,)) + values = _channel_values() + values[FeatureKey.VOLUME] = CHANNEL_VOLUME + + voice.sound(self._INSTRUCTION, values) + + assert values[FeatureKey.VOLUME] == CHANNEL_VOLUME + + def test_the_dimensions_the_instrument_writes_still_sound_its_own(self) -> None: + voice = _voice(GeneratorName.PULSE1, [self._INSTRUCTION], (FeatureKey.VOLUME,)) + + sounded = voice.sound(self._INSTRUCTION, _channel_values()) + + assert sounded.pitch == REFERENCE_PITCH + assert sounded.duty_cycle == DUTY_CYCLE + + 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,)) + values = _channel_values() + + writes.sound(self._INSTRUCTION, values) + + assert holds.sound(self._INSTRUCTION, values).volume == SAMPLE_VOLUME + + 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,)) + + 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], ()) + values = _channel_values() + + assert voice.sound(rest, values).on is False + assert values[FeatureKey.VOLUME] == 0 + + 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], ()) + values = _channel_values() + values[FeatureKey.DUTY_CYCLE] = DUTY_CYCLE + + voice.sound(rest, values) + + assert values[FeatureKey.DUTY_CYCLE] == DUTY_CYCLE diff --git a/tests/unit/sampletones_core/exporters/test_exporter.py b/tests/unit/sampletones_core/exporters/test_exporter.py index 349a4049..4b07b395 100644 --- a/tests/unit/sampletones_core/exporters/test_exporter.py +++ b/tests/unit/sampletones_core/exporters/test_exporter.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Any, Callable, Final, List, Optional, Sequence, Tuple +from typing import Any, Callable, Dict, Final, List, Optional, Sequence, Tuple import numpy as np import pytest @@ -439,3 +439,111 @@ def test_an_instrument_holding_every_dimension_describes_no_frame(self) -> None: ) assert PulseExporter.from_features(features) == [] + + +class TestSingleFrameReading(BaseTestSuite): + """One frame reads into envelope values and back, which is what a player works a tick in. + + A song plays a sample frame by frame and fills in the dimensions its instrument leaves to + the channel, so the two directions `to_features` and `from_features` run over a whole + sequence are needed over a single frame as well. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + exporter: ExporterTypeUnion + instruction: InstructionUnion + silent: InstructionUnion + reference: int + expected: Dict[FeatureKey, int] + + test_cases = ( + TestCase( + label="pulse", + exporter=PulseExporter, + instruction=PulseInstruction( + on=True, + pitch=REFERENCE_PITCH + OCTAVE, + volume=PULSE_VOLUME, + duty_cycle=1, + ), + silent=PulseInstruction.null_instruction(), + reference=REFERENCE_PITCH, + expected={ + FeatureKey.VOLUME: PULSE_VOLUME, + FeatureKey.ARPEGGIO: OCTAVE, + FeatureKey.DUTY_CYCLE: 1, + }, + ), + TestCase( + label="triangle", + exporter=TriangleExporter, + instruction=TriangleInstruction(on=True, pitch=REFERENCE_PITCH - OCTAVE), + silent=TriangleInstruction.null_instruction(), + reference=REFERENCE_PITCH, + expected={ + FeatureKey.VOLUME: MAX_VOLUME, + FeatureKey.ARPEGGIO: -OCTAVE, + }, + ), + TestCase( + label="noise", + exporter=NoiseExporter, + instruction=NoiseInstruction( + on=True, + period=REFERENCE_PERIOD + PERIOD_STEP, + volume=NOISE_VOLUME, + short=True, + ), + silent=NoiseInstruction.null_instruction(), + reference=REFERENCE_PERIOD, + expected={ + FeatureKey.VOLUME: NOISE_VOLUME, + FeatureKey.ARPEGGIO: PERIOD_STEP, + FeatureKey.DUTY_CYCLE: 1, + }, + ), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_a_sounding_frame_states_every_dimension(self, test_case: TestCase) -> None: + values = test_case.exporter.feature_values(test_case.instruction, test_case.reference) + + assert values == test_case.expected + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_a_silent_frame_states_its_level_alone(self, test_case: TestCase) -> None: + """The rest is the channel's, which is how a sequence holds its pitch across a rest.""" + values = test_case.exporter.feature_values(test_case.silent, test_case.reference) + + assert values == {FeatureKey.VOLUME: 0} + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_the_values_a_frame_states_sound_it_back(self, test_case: TestCase) -> None: + values = test_case.exporter.feature_values(test_case.instruction, test_case.reference) + + assert test_case.exporter.instruction_from_values(values, test_case.reference) == test_case.instruction + + @pytest.mark.parametrize( + "test_case", + test_cases, + 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 + + assert test_case.exporter.instruction_from_values(values, test_case.reference) == test_case.instruction diff --git a/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py b/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py index e9285f7e..3879dad4 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py @@ -55,6 +55,20 @@ def _reconstruction(instructions: List[PulseInstruction]) -> Reconstruction: ) +def _saved_playing_channels_only(path: Path) -> Path: + """Writes a reconstruction the way a file saved before the channel set holds one. + + Such a file names a stream for the channels it plays, leaving the rest to be filled in + on the way back. + """ + reconstruction = _reconstruction([_pulse(_BASE_PITCH)]) + reconstruction.instructions_data = [ + item for item in reconstruction.instructions_data if item.generator_name == GeneratorName.PULSE1 + ] + reconstruction.save(path) + return path + + class TestRoundTrip: def test_save_load_round_trip( self, @@ -426,6 +440,27 @@ def test_the_channel_set_survives_a_save_load_round_trip(self, tmp_path: Path) - assert loaded.playing_generators == reconstruction.playing_generators 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 + + 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, + [_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()) + class TestWithNesFrequency: def test_rebuilds_config(self, reconstruction_factory: ReconstructionFactory) -> None: From a12ceb522c032d16c1a217a525dc11242c20f98e Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 12 Aug 2026 22:52:05 +0200 Subject: [PATCH 06/11] Changed: Bitphase interpretation of empty envelope --- docs/formats/bitphase.md | 18 +++++-- docs/guide/interface.md | 4 +- .../formats/bitphase/envelopes.py | 39 ++++++++++++--- .../formats/bitphase/test_envelopes.py | 49 +++++++++++++++++++ 4 files changed, 96 insertions(+), 14 deletions(-) diff --git a/docs/formats/bitphase.md b/docs/formats/bitphase.md index 5973e08f..e0693bb6 100644 --- a/docs/formats/bitphase.md +++ b/docs/formats/bitphase.md @@ -69,7 +69,7 @@ carries every register value the channel takes for that tick. From | Field | Range | Runtime meaning | What the exporter writes | | --- | --- | --- | --- | | `pulseWidth` | 0–3 | square duty cycle; on the noise channel, any nonzero value selects the short LFSR | the duty-cycle envelope item (squares), the short/long mode (noise), a flat value (triangle) | -| `volumeOrRate` | 0–15 | the literal channel volume while `envelope` stays off | the volume envelope item | +| `volumeOrRate` | 0–15 | the literal channel volume while `envelope` stays off | the volume envelope item, or a full level where the slice leaves its volume to the channel | | `envelope` | bool | reads `volumeOrRate` as a hardware decay rate | `false`, so each item is the volume itself | | `soundLength` | 0–511 | length counter in ticks; `0` holds the note | `0`, so the volume envelope alone shapes the note | | `toneAdd` | −4096–4095 | period offset added to the tuning-table period (squares and triangle) | `0` in a document, the pitch contour in a preset | @@ -79,10 +79,18 @@ carries every register value the channel takes for that tick. From **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 since the volume envelope ends on a note-off item, the -instrument rests in silence once it has played through. A sample's `loop` flag drives -this, the same flag the FamiTracker exporter reads. +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. + +**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 +describes. Playback combines a row's level with the pattern's volume column through a +PT3 volume table, where a full-level row comes out at the column's own level, so those +rows sound at whatever level the channel carries — the same reading FamiTracker gives a +disabled volume sequence. A slice describing no frame at all is what writes a single +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 diff --git a/docs/guide/interface.md b/docs/guide/interface.md index efdef079..9d2dc885 100644 --- a/docs/guide/interface.md +++ b/docs/guide/interface.md @@ -51,7 +51,9 @@ 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. **Export instrument...** writes the channel +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. **Export instrument...** writes the channel on show, for whichever tracker the save dialog's file type names — see [where your files live](files.md#exported-files). diff --git a/src/sampletones_core/formats/bitphase/envelopes.py b/src/sampletones_core/formats/bitphase/envelopes.py index 917e4f3e..abea08b4 100644 --- a/src/sampletones_core/formats/bitphase/envelopes.py +++ b/src/sampletones_core/formats/bitphase/envelopes.py @@ -11,6 +11,7 @@ from sampletones_core.formats.bitphase.specification.instruments import ( FLAT_PULSE_WIDTH, LOOP_FROM_START, + MAX_VOLUME_OR_RATE, NO_TABLE_OFFSET, NOISE_MODE_LONG, NOISE_MODE_SHORT, @@ -70,6 +71,22 @@ def _table_offset(generator: GeneratorName, arpeggio: int) -> int: return arpeggio +def _held_volume(frames: int) -> Tuple[int, ...]: + """The volume envelope of a slice whose level the channel governs. + + Bitphase combines each row's level with the pattern's volume column, and a full-level + row comes out at the column's own level, so an instrument holding one for every frame + it describes sounds at whatever level the channel carries. + + Args: + frames: The frames the slice describes. + + Returns: + Tuple[int, ...]: One full-level item per frame. + """ + return (MAX_VOLUME_OR_RATE,) * frames + + def features_to_envelopes( features: Features, generator: GeneratorName, @@ -80,9 +97,14 @@ def features_to_envelopes( 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 - 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, which the volume envelope already leaves - silent, so it rests there once it has played through. + 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. + + A slice describing no frame comes back as the one silent row that is the smallest + instrument Bitphase plays. Args: features: The per-dimension envelopes describing the slice. @@ -98,18 +120,19 @@ def features_to_envelopes( FeatureKey.DUTY_CYCLE: features.duty_cycle, } items = equalize_lengths({key: _to_items(array) for key, array in arrays.items()}, loop) + frames = max(len(values) for values in items.values()) - volumes = items[FeatureKey.VOLUME] - arpeggios = items[FeatureKey.ARPEGGIO] - duty_cycles = items[FeatureKey.DUTY_CYCLE] - - if not volumes: + if not frames: return ChannelEnvelopes( rows=(SILENT_ROW,), table_rows=(NO_TABLE_OFFSET,), loop=LOOP_FROM_START, ) + volumes = items[FeatureKey.VOLUME] or _held_volume(frames) + arpeggios = items[FeatureKey.ARPEGGIO] + duty_cycles = items[FeatureKey.DUTY_CYCLE] + rows = tuple( NesInstrumentRow( pulse_width=_pulse_width(generator, duty_cycles[frame] if duty_cycles else FLAT_PULSE_WIDTH), diff --git a/tests/unit/sampletones_core/formats/bitphase/test_envelopes.py b/tests/unit/sampletones_core/formats/bitphase/test_envelopes.py index 1d409e74..414cbc07 100644 --- a/tests/unit/sampletones_core/formats/bitphase/test_envelopes.py +++ b/tests/unit/sampletones_core/formats/bitphase/test_envelopes.py @@ -12,6 +12,7 @@ from sampletones_core.formats.bitphase.specification.instruments import ( FLAT_PULSE_WIDTH, LOOP_FROM_START, + MAX_VOLUME_OR_RATE, NO_TABLE_OFFSET, NOISE_MODE_LONG, NOISE_MODE_SHORT, @@ -166,6 +167,54 @@ def test_the_loop_row_exists_in_both_lists(self, loop: bool) -> None: assert envelopes.loop < len(envelopes.table_rows) +class TestASliceThatLeavesItsVolumeToTheChannel: + """An instrument with no volume envelope sounds at the level its channel carries, so + every frame it describes reaches Bitphase as a full-level row. + """ + + def test_it_holds_a_full_row_per_frame(self) -> None: + envelopes = features_to_envelopes( + build_features([], arpeggio=PITCH_CONTOUR), + GeneratorName.PULSE1, + loop=False, + ) + assert [row.volume_or_rate for row in envelopes.rows] == [MAX_VOLUME_OR_RATE] * len(PITCH_CONTOUR) + + def test_its_contour_still_moves_the_note(self) -> None: + envelopes = features_to_envelopes( + build_features([], arpeggio=PITCH_CONTOUR), + GeneratorName.PULSE1, + loop=False, + ) + assert list(envelopes.table_rows) == PITCH_CONTOUR + + 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, + loop=False, + ) + assert [row.pulse_width for row in envelopes.rows] == duty_cycles + + 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, + loop=False, + ) + 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), + GeneratorName.PULSE1, + loop=True, + ) + assert len(envelopes.rows) == len(PITCH_CONTOUR) + assert envelopes.loop == LOOP_FROM_START + + class TestAnEmptySlice: """An instrument holds at least one row, so a slice with no volume envelope still reaches Bitphase as a playable silent instrument. From c69fa4eb137894ff72aca3aeba55a51bd49eb525 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 12 Aug 2026 23:35:10 +0200 Subject: [PATCH 07/11] Added: sample size to the samples context menu --- docs/guide/interface.md | 4 +- docs/guide/sequencer.md | 6 +- .../categories/context.py | 22 +++ .../coordinators/tabs/sequencer.py | 2 + .../logic/sequencer/samples.py | 40 ++++- .../parameters/sequencer.py | 3 + .../ui/elements/context_menu.py | 32 +++- .../ui/elements/tree/tree.py | 43 +++-- src/sampletones_application/ui/menu.py | 10 +- .../ui/panels/sequencer/samples.py | 95 +++++++++-- .../logic/sequencer/test_samples.py | 51 ++++++ .../ui/panels/sequencer/test_samples_menu.py | 155 +++++++++++++++++- 12 files changed, 420 insertions(+), 43 deletions(-) diff --git a/docs/guide/interface.md b/docs/guide/interface.md index 9d2dc885..69742a96 100644 --- a/docs/guide/interface.md +++ b/docs/guide/interface.md @@ -53,7 +53,9 @@ 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. **Export instrument...** writes the channel +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. +**Export instrument...** writes the channel on show, for whichever tracker the save dialog's file type names — see [where your files live](files.md#exported-files). diff --git a/docs/guide/sequencer.md b/docs/guide/sequencer.md index d0e24d90..20c3502e 100644 --- a/docs/guide/sequencer.md +++ b/docs/guide/sequencer.md @@ -20,8 +20,10 @@ frequency**; **Add anyway** adds it regardless. 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. Removing a sample that patterns still use asks **Remove sample** first, -because it clears every row that references it. +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. +Removing a sample that patterns still use asks **Remove sample** first, because it +clears every row that references it. ## Writing a pattern diff --git a/src/sampletones_application/categories/context.py b/src/sampletones_application/categories/context.py index 69d18a9b..30a98bcb 100644 --- a/src/sampletones_application/categories/context.py +++ b/src/sampletones_application/categories/context.py @@ -1,6 +1,16 @@ +from typing import Dict, Final + 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 + +CHANNEL_ELEMENTS: Final[Dict[GeneratorName, ContextElements]] = { + GeneratorName.PULSE1: ContextElements.PULSE_1, + GeneratorName.PULSE2: ContextElements.PULSE_2, + GeneratorName.TRIANGLE: ContextElements.TRIANGLE, + GeneratorName.NOISE: ContextElements.NOISE, +} def context_label( @@ -19,3 +29,15 @@ def context_label( TextType.LABEL, element, ] + + +def channel_label( + language_manager: LanguageManager, + generator: GeneratorName, +) -> 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]) diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index c53a14e2..5b7f7f4e 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -276,6 +276,7 @@ def __init__( ) self._sequencer_samples_panel: GUISequencerSamplesPanel = GUISequencerSamplesPanel( layout=layout.sequencer, + detail_color=layout.muted_color, initial_collapsed=session_manager.is_card_collapsed(TAG_SEQUENCER_INSTRUMENTS_PANEL), language_manager=language_manager, key_router=key_router, @@ -601,6 +602,7 @@ def _wire_samples_callbacks(self) -> None: self._sequencer_samples_logic.on_samples_changed = self._on_samples_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( diff --git a/src/sampletones_application/logic/sequencer/samples.py b/src/sampletones_application/logic/sequencer/samples.py index 4f4052b8..49051bf3 100644 --- a/src/sampletones_application/logic/sequencer/samples.py +++ b/src/sampletones_application/logic/sequencer/samples.py @@ -1,7 +1,9 @@ from typing import Callable, Optional from sampletones_application.config.managers.session import SessionManager -from sampletones_application.layout.behavior.scheduling.scheduling import SchedulingBehavior +from sampletones_application.layout.behavior.scheduling.scheduling import ( + SchedulingBehavior, +) 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 @@ -9,7 +11,9 @@ SampleEntryViewModel, SequencerSamplesViewModel, ) +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.reconstructions import Reconstruction from sampletones_core.utils.display import display_sample @@ -73,12 +77,37 @@ def rename_sample(self, sample_id: str, name: str) -> None: def is_sample_used(self, sample_id: str) -> bool: return self._controller.is_sample_used(sample_id) + def build_sample_footprint(self, sample_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 + 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. + + Args: + sample_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) + if sample is None: + return None + + return SampleFootprintViewModel.from_footprints( + reconstruction_footprints(sample.reconstruction, loop=sample.loop) + ) + def sample_name(self, sample_id: str) -> str: return self._controller.project.samples[sample_id].name def sample_position(self, sample_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_sample( + samples=self._controller.project.samples, + sample_id=sample_id, + ) def remove_sample(self, sample_id: str) -> None: self._controller.remove_sample(sample_id) @@ -125,7 +154,12 @@ def _execute_autoplay(self) -> None: if self._session_manager.autoplay: self._play_sample(sample_id, priority=PlaybackPriority.PREVIEW) - def _play_sample(self, sample_id: str, *, priority: PlaybackPriority) -> None: + def _play_sample( + self, + sample_id: str, + *, + priority: PlaybackPriority, + ) -> None: sample = self._controller.project.samples.get(sample_id) if sample is None: return diff --git a/src/sampletones_application/parameters/sequencer.py b/src/sampletones_application/parameters/sequencer.py index 04a3c820..d071ee3c 100644 --- a/src/sampletones_application/parameters/sequencer.py +++ b/src/sampletones_application/parameters/sequencer.py @@ -10,6 +10,7 @@ from sampletones_application.layout.tabs.sequencer import SequencerLayout from sampletones_application.parameters.geometry import TabGeometry from sampletones_application.ui.elements.tree.colors import TreeColors +from sampletones_application.utils.palette.colors.base import BaseColor @dataclass(frozen=True) @@ -33,6 +34,7 @@ class SequencerTabParameters: plus_minus: PlusMinusButtonsLayout feature_colors: FeatureColors tree_colors: TreeColors + muted_color: BaseColor scheduling: SchedulingBehavior @classmethod @@ -52,5 +54,6 @@ def from_config(cls, config: LayoutConfig) -> SequencerTabParameters: general.colors, accent=general.colors.headers.reconstruction, ), + muted_color=general.colors.text.disabled, scheduling=config.behavior.scheduling, ) diff --git a/src/sampletones_application/ui/elements/context_menu.py b/src/sampletones_application/ui/elements/context_menu.py index b882407d..0e456f6d 100644 --- a/src/sampletones_application/ui/elements/context_menu.py +++ b/src/sampletones_application/ui/elements/context_menu.py @@ -1,8 +1,12 @@ import contextlib -from typing import Iterator +from typing import Iterator, Sequence, Tuple import dearpygui.dearpygui as dpg +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.palette.colors.base import BaseColor from sampletones_shared.types.callback import VoidCallback @@ -43,3 +47,29 @@ def add_play_menu_item( shortcut=shortcut, callback=on_play, ) + + +def add_detail_items( + items: Sequence[Tuple[str, str]], + *, + color: BaseColor, +) -> None: + """Add a block of read-only ``label: value`` lines to the context menu being built. + + A menu states what its target is alongside what can be done to it: a file browser prints the + settings a reconstruction was made with, and the samples menu prints the bytes a sample + occupies. Both read as the same tinted, monospaced block under a separator of its own, so the + facts stay apart from the items a reader clicks. + + Args: + items: The label and value of each line, in the order the menu prints them. + color: The tint the lines take, which marks them as facts rather than actions. + """ + if not items: + return + + dpg.add_separator() + for label, value in items: + detail_text = dpg.add_text(f"{label}: {value}") + dpg_set_palette_color(detail_text, color) + FontRegistry.bind_to_item(detail_text, Font.MONO_SMALL) diff --git a/src/sampletones_application/ui/elements/tree/tree.py b/src/sampletones_application/ui/elements/tree/tree.py index 3c7a2ad7..18b37a05 100644 --- a/src/sampletones_application/ui/elements/tree/tree.py +++ b/src/sampletones_application/ui/elements/tree/tree.py @@ -33,7 +33,10 @@ TAG_INSTRUCTIONS_LIBRARY_THEME_INSTRUCTION, ) from sampletones_application.ui.elements.button import GUIButton -from sampletones_application.ui.elements.context_menu import add_play_menu_item +from sampletones_application.ui.elements.context_menu import ( + add_detail_items, + add_play_menu_item, +) 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 @@ -390,7 +393,11 @@ def double_click_callback( ) -> None: user_data = dpg.get_item_user_data(app_data[1]) if item_double_click_callback is not None: - item_double_click_callback(sender, app_data, user_data=user_data) + item_double_click_callback( + sender, + app_data, + user_data=user_data, + ) return double_click_callback @@ -570,22 +577,17 @@ def _reconstruction_detail_items(self, directory_name: str) -> List[Tuple[str, s ] def _add_context_menu_details(self, node: TreeNode) -> None: - detail_items = self._node_detail_items(node) - if not detail_items: - return - - dpg.add_separator() - for label, value in detail_items: - detail_text = dpg.add_text(f"{label}: {value}") - dpg_set_palette_color(detail_text, self._colors.muted) - FontRegistry.bind_to_item(detail_text, Font.MONO_SMALL) + add_detail_items(self._node_detail_items(node), color=self._colors.muted) def _add_context_menu_play_item(self, node: FileSystemNode) -> None: if not self._logic.is_playable_file(node): return dpg.add_separator() - add_play_menu_item(self._language_manager["global.context.label.play"], lambda: self._logic.play_node(node)) + add_play_menu_item( + self._language_manager["global.context.label.play"], + lambda: self._logic.play_node(node), + ) def _add_context_menu_path_items(self, path: Path) -> None: dpg.add_separator() @@ -723,7 +725,11 @@ def _update_node_visibility_recursive(self, node: TreeNode) -> None: for child in node.children: self._update_node_visibility_recursive(child) - def apply_filter(self, query: str, predicate: Callable[[TreeNode, str], bool]) -> None: + def apply_filter( + self, + query: str, + predicate: Callable[[TreeNode, str], bool], + ) -> None: self.tree.apply_filter(query, predicate) def clear_filter(self) -> None: @@ -759,7 +765,10 @@ def _resolve_node_theme_tag( if isinstance(node, FileSystemNode): match node.node_type: case NodeType.DIRECTORY: - return self._resolve_directory_theme_tag(node, has_favorite_ancestor=has_favorite_ancestor) + return self._resolve_directory_theme_tag( + node, + has_favorite_ancestor=has_favorite_ancestor, + ) case NodeType.FILE: return self._resolve_file_theme_tag( node, @@ -823,7 +832,11 @@ def _resolve_other_theme_tag(self, node: TreeNode) -> str: case _: return TAG_GLOBAL_THEME_DEFAULT - def _reapply_theme_recursively(self, node: FileSystemNode, has_favorite_ancestor: bool = False) -> None: + def _reapply_theme_recursively( + self, + node: FileSystemNode, + has_favorite_ancestor: bool = False, + ) -> None: node_tag = self._generate_node_tag(node) if not dpg.does_item_exist(node_tag): return diff --git a/src/sampletones_application/ui/menu.py b/src/sampletones_application/ui/menu.py index 377429b8..0894f533 100644 --- a/src/sampletones_application/ui/menu.py +++ b/src/sampletones_application/ui/menu.py @@ -3,7 +3,7 @@ import dearpygui.dearpygui as dpg -from sampletones_application.categories.context import context_label +from sampletones_application.categories.context import channel_label, context_label from sampletones_application.categories.elements.global_ import ( ContextElements, MenuElements, @@ -113,12 +113,6 @@ ContextElements.PASTE, ContextElements.DELETE, ) -CHANNEL_LABELS: Final[Dict[GeneratorName, ContextElements]] = { - GeneratorName.PULSE1: ContextElements.PULSE_1, - GeneratorName.PULSE2: ContextElements.PULSE_2, - GeneratorName.TRIANGLE: ContextElements.TRIANGLE, - GeneratorName.NOISE: ContextElements.NOISE, -} class MenuBar: @@ -516,7 +510,7 @@ def _create_channels_menu(self, state: MenuBarViewModel) -> None: shortcut_id, callback=partial(self._on_channel_muted, generator), tag=self._channel_menu_item_tag(generator), - label=self._context_label(CHANNEL_LABELS[generator]), + label=channel_label(self._language_manager, generator), check=True, default_value=not state.channels.is_muted(generator), ) diff --git a/src/sampletones_application/ui/panels/sequencer/samples.py b/src/sampletones_application/ui/panels/sequencer/samples.py index 1ccc4c98..dc89d966 100644 --- a/src/sampletones_application/ui/panels/sequencer/samples.py +++ b/src/sampletones_application/ui/panels/sequencer/samples.py @@ -3,9 +3,11 @@ import dearpygui.dearpygui as dpg -from sampletones_application.categories.context import context_label +from sampletones_application.categories.context import channel_label, context_label from sampletones_application.categories.elements.global_ import ContextElements -from sampletones_application.categories.elements.sequencer import SequencerInstrumentsElements +from sampletones_application.categories.elements.sequencer import ( + SequencerInstrumentsElements, +) from sampletones_application.categories.hierarchy import Page, Panel, TextType from sampletones_application.categories.manager import LanguageManager from sampletones_application.layout.tabs.sequencer import SequencerLayout @@ -19,6 +21,7 @@ TAG_SEQUENCER_INSTRUMENTS_WINDOW, ) from sampletones_application.ui.elements.context_menu import ( + add_detail_items, add_play_menu_item, context_menu, ) @@ -36,13 +39,16 @@ ) from sampletones_application.utils.gui.shortcuts.ids import ShortcutCategory, 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.move import MoveDirection from sampletones_application.view_model.sequencer.samples import ( SampleEntryViewModel, SampleSelection, SequencerSamplesViewModel, ) -from sampletones_core.utils.display import display_id, display_sample_label +from sampletones_application.view_model.shared.footprint import SampleFootprintViewModel +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.utils.display import display_id from sampletones_shared.types.application import Sender from sampletones_shared.types.callback import StringCallback @@ -89,6 +95,7 @@ def __init__( self, *, layout: SequencerLayout, + detail_color: BaseColor, language_manager: LanguageManager, key_router: KeyRouter, tab_active: ActivePredicate, @@ -97,6 +104,7 @@ 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 @@ -106,6 +114,9 @@ def __init__( self._selected_row: Optional[int] = None self._editing_sample_id: Optional[str] = None self._entries: Tuple[SampleEntryViewModel, ...] = () + self._lbl_sample_size = language_manager["global.context.label.sample_size"] + self._tpl_size_bytes = language_manager["global.context.template.size_bytes"] + self.sample_footprint: Optional[Callable[[str], Optional[SampleFootprintViewModel]]] = 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 @@ -175,17 +186,26 @@ def _create_samples_table(self) -> None: ), ): dpg.add_table_column( - label=self._label(self._language_manager, SequencerInstrumentsElements.COLUMN_ID), + label=self._label( + self._language_manager, + SequencerInstrumentsElements.COLUMN_ID, + ), width_fixed=True, init_width_or_weight=self._layout.table_cells.instrument.id, ) dpg.add_table_column( - label=self._label(self._language_manager, SequencerInstrumentsElements.COLUMN_NAME), + label=self._label( + self._language_manager, + SequencerInstrumentsElements.COLUMN_NAME, + ), width_stretch=True, init_width_or_weight=self._layout.table_cells.instrument.name, ) dpg.add_table_column( - label=self._label(self._language_manager, SequencerInstrumentsElements.COLUMN_LOOP), + label=self._label( + self._language_manager, + SequencerInstrumentsElements.COLUMN_LOOP, + ), width_fixed=True, init_width_or_weight=self._layout.table_cells.instrument.loop, ) @@ -211,7 +231,11 @@ def _rebuild(self) -> None: if self._selected_row is None: self._selected_sample_id = None - def _build_sample_row(self, position: int, entry: SampleEntryViewModel) -> None: + def _build_sample_row( + self, + position: int, + entry: SampleEntryViewModel, + ) -> None: row_id = dpg.add_table_row(parent=TAG_SEQUENCER_INSTRUMENTS_TABLE) self._build_id_cell(row_id, position, entry) self._build_name_cell(row_id, position, entry) @@ -530,6 +554,10 @@ def _show_context_menu(self, position: int, sample_id: str) -> None: with context_menu(): header = dpg.add_text(target.label) FontRegistry.bind_to_item(header, Font.MONO_BOLD) + add_detail_items( + self._footprint_items(sample_id), + color=self._detail_color, + ) dpg.add_separator() add_play_menu_item( context_label(self._language_manager, ContextElements.PLAY), @@ -541,6 +569,33 @@ def _show_context_menu(self, position: int, sample_id: str) -> None: dpg.add_separator() self.add_action_items(target) + def _footprint_items(self, sample_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) + if footprint is None: + 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) + if instrument_bytes is not None: + items.append( + ( + channel_label(self._language_manager, generator_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 owns_edit_actions(self) -> bool: """Whether the Edit menu states this panel's actions, which it does while it holds a sample. @@ -563,21 +618,33 @@ def add_action_items(self, target: SampleSelection) -> None: selection holds. An action added here reaches both, printing the key it answers to. """ dpg.add_menu_item( - label=self._label(self._language_manager, SequencerInstrumentsElements.CONTEXT_EDIT), + label=self._label( + self._language_manager, + SequencerInstrumentsElements.CONTEXT_EDIT, + ), callback=lambda: self.call(self.on_sample_edit_requested, target.sample_id), ) dpg.add_menu_item( - label=self._label(self._language_manager, SequencerInstrumentsElements.CONTEXT_RENAME), + label=self._label( + self._language_manager, + SequencerInstrumentsElements.CONTEXT_RENAME, + ), shortcut=self._shortcuts.display(ShortcutId.SAMPLES_RENAME_SAMPLE), callback=lambda: self._start_rename(target.sample_id), ) dpg.add_menu_item( - label=self._label(self._language_manager, SequencerInstrumentsElements.CONTEXT_DUPLICATE), + label=self._label( + self._language_manager, + SequencerInstrumentsElements.CONTEXT_DUPLICATE, + ), callback=lambda: self.call(self.on_duplicate_requested, target.sample_id), ) dpg.add_separator() dpg.add_menu_item( - label=self._label(self._language_manager, SequencerInstrumentsElements.CONTEXT_REMOVE), + label=self._label( + self._language_manager, + SequencerInstrumentsElements.CONTEXT_REMOVE, + ), shortcut=self._shortcuts.display(ShortcutId.SAMPLES_REMOVE_SAMPLE), callback=lambda: self.call(self.on_remove_requested, target.sample_id), ) @@ -596,7 +663,11 @@ def _add_move_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.sample_id, position), + callback=lambda: self.call( + self.on_move_requested, + target.sample_id, + position, + ), ) @staticmethod diff --git a/tests/unit/sampletones_application/logic/sequencer/test_samples.py b/tests/unit/sampletones_application/logic/sequencer/test_samples.py index c6740773..d4c8c5b5 100644 --- a/tests/unit/sampletones_application/logic/sequencer/test_samples.py +++ b/tests/unit/sampletones_application/logic/sequencer/test_samples.py @@ -7,9 +7,12 @@ from sampletones_application.logic.project.manager import ProjectManager 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.formats.famitracker.footprint import reconstruction_footprints from sampletones_core.project.instruments.instrument import Instrument from sampletones_core.reconstructions import Reconstruction +from tests.suite.sequencer import sample_reconstruction def _logic() -> Tuple[ProjectController, SequencerSamplesLogic]: @@ -163,6 +166,54 @@ def test_lists_added_samples_in_insertion_order( ] +class TestBuildSampleFootprint: + """The samples menu prints what a sample occupies, measured the way the sample is placed.""" + + 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") + + footprint = logic.build_sample_footprint(sample.id) + + assert footprint is not None + assert [instrument.generator for instrument in footprint.instruments] == list(generators) + + def test_it_measures_the_sample_under_its_own_loop_flag( + self, + reconstruction_factory: Callable[[], Reconstruction], + ) -> None: + controller, logic = _logic() + sample = controller.add_sample(reconstruction_factory(), name="lead") + controller.set_sample_loop(sample.id, True) + + footprint = logic.build_sample_footprint(sample.id) + + assert footprint == SampleFootprintViewModel.from_footprints( + reconstruction_footprints(sample.reconstruction, loop=True) + ) + + def test_a_looping_sample_costs_less_than_a_one_shot( + self, + reconstruction_factory: Callable[[], Reconstruction], + ) -> None: + """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) + + controller.set_sample_loop(sample.id, True) + looping = logic.build_sample_footprint(sample.id) + + assert one_shot is not None and looping is not None + assert looping.total_bytes < one_shot.total_bytes + + def test_a_sample_the_pool_has_dropped_is_measured_nowhere(self) -> None: + _, logic = _logic() + + assert logic.build_sample_footprint("missing") 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_samples_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_samples_menu.py index c2a64353..9326e8c0 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 @@ -1,13 +1,24 @@ +import contextlib from dataclasses import dataclass, field -from typing import Any, Callable, List, Optional, Tuple +from typing import Any, Callable, Iterator, List, Optional, Tuple import pytest +from sampletones_application.categories.elements.global_ import ContextElements from sampletones_application.categories.elements.sequencer import SequencerInstrumentsElements +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.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.shared.footprint import ( + InstrumentSizeViewModel, + SampleFootprintViewModel, +) +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.utils.display import display_sample_label from tests.suite.shortcuts import shipped_source ENTRIES: Tuple[SampleEntryViewModel, ...] = ( @@ -19,6 +30,19 @@ SELECTED_ID = "bass-id" SELECTED_ROW = 1 +SAMPLE_SIZE_LABEL = "Sample size" +SIZE_TEMPLATE = "{bytes} B" +DETAIL_COLOR = LiteralColor((0, 0, 0, 255)) + +PULSE_1_BYTES = 41 +NOISE_BYTES = 19 +FOOTPRINT = SampleFootprintViewModel( + instruments=( + InstrumentSizeViewModel(generator=GeneratorName.PULSE1, total_bytes=PULSE_1_BYTES), + InstrumentSizeViewModel(generator=GeneratorName.NOISE, total_bytes=NOISE_BYTES), + ), +) + EDIT_ITEM = 0 RENAME_ITEM = 1 DUPLICATE_ITEM = 2 @@ -89,6 +113,8 @@ def _panel( tab_active: bool = True, editing: Optional[str] = None, field_focused: bool = False, + footprint: Optional[SampleFootprintViewModel] = FOOTPRINT, + footprint_wired: bool = True, ) -> SamplesPanelFixture: """A samples panel whose menu builder can run with no DearPyGui context behind it.""" panel = GUISequencerSamplesPanel.__new__(GUISequencerSamplesPanel) @@ -100,6 +126,10 @@ def _panel( panel._editing_sample_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.sample_footprint = (lambda _sample_id: footprint) if footprint_wired else None requests = Requests() panel.on_sample_edit_requested = requests.edited.append @@ -117,6 +147,61 @@ def __getitem__(self, key: Tuple[Any, ...]) -> str: return str(key[-1].value) +@dataclass(frozen=True) +class MenuWidget: + """One widget as the menu registered it, which is the whole of what a reader meets.""" + + kind: str + text: str + + +class _MenuBuildRecorder: + """Every widget a whole menu build registers, in the order they are printed.""" + + def __init__(self) -> None: + self.widgets: List[MenuWidget] = [] + + def add_text(self, text: str, **_kwargs: Any) -> int: + self.widgets.append(MenuWidget(kind="text", text=text)) + return 0 + + def add_separator(self, **_kwargs: Any) -> int: + self.widgets.append(MenuWidget(kind="separator", text="")) + return 0 + + def add_menu_item(self, **kwargs: Any) -> int: + self.widgets.append(MenuWidget(kind="item", text=kwargs["label"])) + return 0 + + def texts_before_the_first_item(self) -> List[str]: + widgets: List[str] = [] + for widget in self.widgets: + if widget.kind == "item": + break + if widget.kind == "text": + widgets.append(widget.text) + + return widgets + + +@contextlib.contextmanager +def _null_menu() -> Iterator[None]: + yield + + +@pytest.fixture +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(context_menu_module, "dpg_set_palette_color", lambda _item, _color: None) + monkeypatch.setattr(FontRegistry, "bind_to_item", lambda _item, _font: None) + return recorded + + @dataclass(frozen=True) class _Router: """The key router as the panel's own scope reads it.""" @@ -194,6 +279,74 @@ def test_a_move_with_nowhere_to_go_is_greyed_out( assert recorder.items[MOVE_BOTTOM_ITEM].enabled +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) + + assert items == [ + (SAMPLE_SIZE_LABEL, f"{PULSE_1_BYTES + NOISE_BYTES} B"), + (ContextElements.PULSE_1.value, f"{PULSE_1_BYTES} B"), + (ContextElements.NOISE.value, f"{NOISE_BYTES} B"), + ] + + 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)] + + 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: + """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) + return FOOTPRINT + + fixture = _panel(monkeypatch) + fixture.panel.sample_footprint = _measure + + fixture.panel._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_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) == [] + + +class TestMenuComposition: + def test_the_sizes_sit_between_the_sample_name_and_the_actions( + self, + monkeypatch: pytest.MonkeyPatch, + build_recorder: _MenuBuildRecorder, + ) -> None: + """Pins where the figures are printed: under the name they belong to, above what can be done.""" + _panel(monkeypatch).panel._show_context_menu(SELECTED_ROW, SELECTED_ID) + + assert build_recorder.texts_before_the_first_item() == [ + display_sample_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", + ] + + def test_a_menu_with_no_figures_reads_as_it_always_has( + self, + monkeypatch: pytest.MonkeyPatch, + build_recorder: _MenuBuildRecorder, + ) -> 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")] + + class TestEditActions: def test_the_panel_answers_while_it_holds_a_selection(self, monkeypatch: pytest.MonkeyPatch) -> None: assert _panel(monkeypatch).panel.owns_edit_actions() From 84a71b2e346f3ec9299af48faa4123824016801f Mon Sep 17 00:00:00 2001 From: JakimPL Date: Thu, 13 Aug 2026 16:21:19 +0200 Subject: [PATCH 08/11] Refactored: channel labels, pitch tooltips and footprint totals --- docs/development/guidelines.md | 2 +- .../categories/pitch.py | 96 +++++++++++++++---- .../ui/panels/instruction/choice.py | 15 +-- .../ui/panels/main/reconstructor.py | 30 ++---- .../reconstruction/instruments/instruments.py | 21 ++-- .../ui/panels/reconstruction/plot.py | 23 +---- .../ui/themes/channels.py | 16 ++++ .../view_model/shared/footprint.py | 28 ++++-- .../reconstruction/test_instruments_panel.py | 74 +++++++------- .../ui/panels/sequencer/test_samples_menu.py | 22 ++--- 10 files changed, 183 insertions(+), 144 deletions(-) create mode 100644 src/sampletones_application/ui/themes/channels.py diff --git a/docs/development/guidelines.md b/docs/development/guidelines.md index 75ff89bd..313d3d53 100644 --- a/docs/development/guidelines.md +++ b/docs/development/guidelines.md @@ -31,7 +31,7 @@ These rules govern the Python in this repository. They complement 1. An `__init__` exposes only names from within its own tree hierarchy. 1. Give each module a single area of responsibility. 1. If a module contains many class and function definitions, split into a subpackage divided by a single concern. -1. If a private function 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. 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. diff --git a/src/sampletones_application/categories/pitch.py b/src/sampletones_application/categories/pitch.py index 22bcda63..c5235a70 100644 --- a/src/sampletones_application/categories/pitch.py +++ b/src/sampletones_application/categories/pitch.py @@ -1,21 +1,77 @@ +from dataclasses import dataclass +from typing import Self + from sampletones_application.categories.manager import LanguageManager -from sampletones_core.utils.pitch_kind import PERIOD_VALUE_KIND, PitchValueKind - - -def build_pitch_tooltip( - language_manager: LanguageManager, - kind: PitchValueKind, - template: str, -) -> str: - """Fills a pitch stepper's help template with the shared example for ``kind``: the quantity's name - ("pitch" or "period"), an example note name, and the matching numeric value. The value is resolved - from the example name through the kind itself, so the name and value the tooltip shows always agree. - Both the reconstruction and instruction steppers compose their tooltips through here, keeping one - definition of the example while each supplies its own surrounding wording via ``template``.""" - is_period = kind is PERIOD_VALUE_KIND - type_name = language_manager["global.pitch.label.period_name" if is_period else "global.pitch.label.pitch_name"] - example_name = language_manager[ - "global.pitch.label.period_example" if is_period else "global.pitch.label.pitch_example" - ] - example_value = kind.from_text(example_name, kind.minimum) - return template.format(type_name, example_name, example_value) +from sampletones_core.utils.pitch_kind import ( + PERIOD_VALUE_KIND, + PITCH_VALUE_KIND, + PitchValueKind, +) + + +@dataclass(frozen=True) +class PitchTooltips: + """A pitch stepper's help in both readings, so a panel resolves the one its field takes. + + A stepper states a pitch on the tonal channels and a period on the noise channel, and a panel + holding steppers of both kinds phrases each from the same template. Building the pair together + keeps the two readings in step and leaves the choice to the moment a field is drawn. + + Attributes: + pitch: The help a stepper reading a pitch shows. + period: The help a stepper reading a period shows. + """ + + pitch: str + period: str + + @classmethod + def build( + cls, + language_manager: LanguageManager, + template: str, + ) -> Self: + """Phrases both readings from one template. + + Args: + language_manager: Where the example note name and value are read from. + template: The panel's own surrounding wording. + + Returns: + PitchTooltips: The help in both readings. + """ + return cls( + pitch=cls.build_pitch_tooltip( + language_manager, + PITCH_VALUE_KIND, + template, + ), + period=cls.build_pitch_tooltip( + language_manager, + PERIOD_VALUE_KIND, + template, + ), + ) + + def for_kind(self, kind: PitchValueKind) -> str: + """The help a stepper of ``kind`` shows.""" + return self.period if kind is PERIOD_VALUE_KIND else self.pitch + + @staticmethod + def build_pitch_tooltip( + language_manager: LanguageManager, + kind: PitchValueKind, + template: str, + ) -> str: + """Fills a pitch stepper's help template with the shared example for ``kind``: the quantity's name + ("pitch" or "period"), an example note name, and the matching numeric value. The value is resolved + from the example name through the kind itself, so the name and value the tooltip shows always agree. + Both the reconstruction and instruction steppers compose their tooltips through here, keeping one + definition of the example while each supplies its own surrounding wording via ``template``.""" + is_period = kind is PERIOD_VALUE_KIND + type_name = language_manager["global.pitch.label.period_name" if is_period else "global.pitch.label.pitch_name"] + example_name = language_manager[ + "global.pitch.label.period_example" if is_period else "global.pitch.label.pitch_example" + ] + example_value = kind.from_text(example_name, kind.minimum) + return template.format(type_name, example_name, example_value) diff --git a/src/sampletones_application/ui/panels/instruction/choice.py b/src/sampletones_application/ui/panels/instruction/choice.py index cd6da0ae..c2123529 100644 --- a/src/sampletones_application/ui/panels/instruction/choice.py +++ b/src/sampletones_application/ui/panels/instruction/choice.py @@ -3,7 +3,7 @@ import dearpygui.dearpygui as dpg from sampletones_application.categories.manager import LanguageManager -from sampletones_application.categories.pitch import build_pitch_tooltip +from sampletones_application.categories.pitch import PitchTooltips from sampletones_application.layout.tabs.instructions import InstructionsLayout from sampletones_application.tags.compose import compose_tag from sampletones_application.tags.general import SUF_HANDLER_REGISTRY @@ -73,16 +73,9 @@ def __init__( self._pitch_stepper: Optional[GUIPitchStepper] = None self._msg_status_input = language_manager["global.status.message.input"] - tooltip_template = language_manager["instructions.details.template.pitch_tooltip_template"] - self._pitch_tooltip = build_pitch_tooltip( + self._pitch_tooltips = PitchTooltips.build( language_manager, - PITCH_VALUE_KIND, - tooltip_template, - ) - self._period_tooltip = build_pitch_tooltip( - language_manager, - PERIOD_VALUE_KIND, - tooltip_template, + language_manager["instructions.details.template.pitch_tooltip_template"], ) super().__init__( @@ -170,7 +163,7 @@ def _create_pitch_stepper( kind=kind, initial_value=initial_value, label=label, - tooltip=self._period_tooltip if is_period else self._pitch_tooltip, + tooltip=self._pitch_tooltips.for_kind(kind), status_message=( self._language_manager["instructions.details.message.status_input_period"] if is_period diff --git a/src/sampletones_application/ui/panels/main/reconstructor.py b/src/sampletones_application/ui/panels/main/reconstructor.py index df579fec..398f686a 100644 --- a/src/sampletones_application/ui/panels/main/reconstructor.py +++ b/src/sampletones_application/ui/panels/main/reconstructor.py @@ -2,17 +2,12 @@ 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.inputs import InputsLayout from sampletones_application.layout.tabs.main.reconstructor import ReconstructorLayout from sampletones_application.tags.compose import compose_tag -from sampletones_application.tags.general import ( - SUF_HANDLER_REGISTRY, - TAG_GLOBAL_THEME_CHANNEL_NOISE, - TAG_GLOBAL_THEME_CHANNEL_PULSE1, - TAG_GLOBAL_THEME_CHANNEL_PULSE2, - TAG_GLOBAL_THEME_CHANNEL_TRIANGLE, -) +from sampletones_application.tags.general import SUF_HANDLER_REGISTRY from sampletones_application.tags.main import ( PRE_MAIN_RECONSTRUCTOR_GENERATOR, TAG_MAIN_RECONSTRUCTOR_PANEL, @@ -23,6 +18,7 @@ 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.themes.channels import CHANNEL_THEME_TAGS from sampletones_application.ui.themes.registry import ThemeRegistry from sampletones_application.utils.gui.dpg import dpg_set_value from sampletones_application.utils.gui.tooltip import show_tooltip @@ -98,21 +94,11 @@ def _create_generator_selection(self) -> None: def _generator_chips(self) -> List[Tuple[GeneratorName, str, str]]: return [ ( - GeneratorName.PULSE1, - self._language_manager["global.context.label.pulse_1"], - TAG_GLOBAL_THEME_CHANNEL_PULSE1, - ), - ( - GeneratorName.PULSE2, - self._language_manager["global.context.label.pulse_2"], - TAG_GLOBAL_THEME_CHANNEL_PULSE2, - ), - ( - GeneratorName.TRIANGLE, - self._language_manager["global.context.label.triangle"], - TAG_GLOBAL_THEME_CHANNEL_TRIANGLE, - ), - (GeneratorName.NOISE, self._language_manager["global.context.label.noise"], TAG_GLOBAL_THEME_CHANNEL_NOISE), + generator_name, + channel_label(self._language_manager, generator_name), + CHANNEL_THEME_TAGS[generator_name], + ) + for generator_name in GeneratorName.items() ] def _create_drive_slider(self) -> None: diff --git a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py index c3766a7f..c4b31304 100644 --- a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py +++ b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py @@ -4,8 +4,9 @@ import dearpygui.dearpygui as dpg import numpy as np +from sampletones_application.categories.context import channel_label from sampletones_application.categories.manager import LanguageManager -from sampletones_application.categories.pitch import build_pitch_tooltip +from sampletones_application.categories.pitch import PitchTooltips from sampletones_application.layout.general.colors.feature import FeatureColors from sampletones_application.layout.graphs import GraphsLayout from sampletones_application.tags.compose import compose_tag @@ -137,22 +138,12 @@ def __init__( self._lbl_sample_size = language_manager["global.context.label.sample_size"] self._lbl_instrument_size = language_manager["global.context.label.instrument_size"] self._tpl_size_bytes = language_manager["global.context.template.size_bytes"] - tooltip_template = language_manager["reconstructions.instruments.template.initial_pitch_tooltip_template"] - self._pitch_tooltip = build_pitch_tooltip( + self._pitch_tooltips = PitchTooltips.build( language_manager, - PITCH_VALUE_KIND, - tooltip_template, - ) - self._period_tooltip = build_pitch_tooltip( - language_manager, - PERIOD_VALUE_KIND, - tooltip_template, + language_manager["reconstructions.instruments.template.initial_pitch_tooltip_template"], ) self._generator_labels: Dict[GeneratorName, str] = { - GeneratorName.PULSE1: language_manager["global.context.label.pulse_1"], - GeneratorName.PULSE2: language_manager["global.context.label.pulse_2"], - GeneratorName.TRIANGLE: language_manager["global.context.label.triangle"], - GeneratorName.NOISE: language_manager["global.context.label.noise"], + generator_name: channel_label(language_manager, generator_name) for generator_name in GeneratorName.items() } super().__init__( @@ -568,7 +559,7 @@ def _create_pitch_stepper( if is_noise else self._language_manager["reconstructions.instruments.label.initial_pitch"] ), - tooltip=self._period_tooltip if is_noise else self._pitch_tooltip, + tooltip=self._pitch_tooltips.for_kind(kind), status_message=( self._language_manager["reconstructions.instruments.message.status_input_period"] if is_noise diff --git a/src/sampletones_application/ui/panels/reconstruction/plot.py b/src/sampletones_application/ui/panels/reconstruction/plot.py index be400693..d1913233 100644 --- a/src/sampletones_application/ui/panels/reconstruction/plot.py +++ b/src/sampletones_application/ui/panels/reconstruction/plot.py @@ -2,15 +2,10 @@ import dearpygui.dearpygui as dpg +from sampletones_application.categories.context import channel_label from sampletones_application.categories.manager import LanguageManager from sampletones_application.layout.graphs import GraphsLayout from sampletones_application.tags.compose import compose_tag -from sampletones_application.tags.general import ( - TAG_GLOBAL_THEME_CHANNEL_NOISE, - TAG_GLOBAL_THEME_CHANNEL_PULSE1, - TAG_GLOBAL_THEME_CHANNEL_PULSE2, - TAG_GLOBAL_THEME_CHANNEL_TRIANGLE, -) from sampletones_application.tags.reconstructions import ( PRE_RECONSTRUCTION_GENERATOR, SUF_RECONSTRUCTIONS_RECONSTRUCTION_AUTOSCALE, @@ -23,6 +18,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.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_set_value from sampletones_application.utils.gui.tooltip import show_tooltip @@ -34,13 +30,6 @@ from sampletones_shared.types.application import Sender from sampletones_shared.types.callback import MessageCallback -_GENERATOR_THEME_TAGS = { - 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, -} - class GUIReconstructionPlotPanel(GUIPanel): def __init__( @@ -102,7 +91,7 @@ def update_view(self, view_model: ReconstructionViewModel) -> None: ) dpg_set_value(tag, is_selected) if is_playing: - ThemeRegistry.get(_GENERATOR_THEME_TAGS[generator_name]).bind_to_item(tag) + ThemeRegistry.get(CHANNEL_THEME_TAGS[generator_name]).bind_to_item(tag) else: dpg.bind_item_theme(tag, 0) @@ -167,10 +156,8 @@ def _create_waveform_display(self) -> None: def _create_generator_checkboxes(self) -> None: generator_labels = { - GeneratorName.PULSE1: self._language_manager["global.context.label.pulse_1"], - GeneratorName.PULSE2: self._language_manager["global.context.label.pulse_2"], - GeneratorName.TRIANGLE: self._language_manager["global.context.label.triangle"], - GeneratorName.NOISE: self._language_manager["global.context.label.noise"], + generator_name: channel_label(self._language_manager, generator_name) + for generator_name in GeneratorName.items() } with dpg.group( diff --git a/src/sampletones_application/ui/themes/channels.py b/src/sampletones_application/ui/themes/channels.py new file mode 100644 index 00000000..c3786554 --- /dev/null +++ b/src/sampletones_application/ui/themes/channels.py @@ -0,0 +1,16 @@ +from typing import Dict, Final + +from sampletones_application.tags.general import ( + TAG_GLOBAL_THEME_CHANNEL_NOISE, + TAG_GLOBAL_THEME_CHANNEL_PULSE1, + TAG_GLOBAL_THEME_CHANNEL_PULSE2, + TAG_GLOBAL_THEME_CHANNEL_TRIANGLE, +) +from sampletones_core.constants.enums import GeneratorName + +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, +} diff --git a/src/sampletones_application/view_model/shared/footprint.py b/src/sampletones_application/view_model/shared/footprint.py index 4607001c..b8578a31 100644 --- a/src/sampletones_application/view_model/shared/footprint.py +++ b/src/sampletones_application/view_model/shared/footprint.py @@ -3,14 +3,26 @@ from pydantic import BaseModel from sampletones_core.constants.enums import GeneratorName -from sampletones_core.formats.famitracker.footprint import InstrumentFootprint +from sampletones_core.formats.famitracker.footprint import ( + InstrumentFootprint, + total_footprint, +) class InstrumentSizeViewModel(BaseModel, frozen=True): - """The bytes one channel's instrument occupies once a tracker compiles it.""" + """The bytes one channel's 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. + """ generator: GeneratorName - total_bytes: int + footprint: InstrumentFootprint + + @property + def total_bytes(self) -> int: + """The bytes this channel's instrument occupies, its two regions together.""" + return self.footprint.total_bytes class SampleFootprintViewModel(BaseModel, frozen=True): @@ -34,7 +46,7 @@ def from_footprints( instruments=tuple( InstrumentSizeViewModel( generator=generator_name, - total_bytes=footprints[generator_name].total_bytes, + footprint=footprints[generator_name], ) for generator_name in GeneratorName.items() if generator_name in footprints @@ -43,8 +55,12 @@ def from_footprints( @property def total_bytes(self) -> int: - """The bytes the whole sample occupies, its instruments summed.""" - return sum(instrument.total_bytes for instrument in self.instruments) + """The bytes the whole sample 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 same way. + """ + return total_footprint(instrument.footprint for instrument in self.instruments).total_bytes def bytes_for(self, generator: GeneratorName) -> Optional[int]: """The bytes one channel's instrument occupies, where the sample covers that channel.""" 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 128ca0ba..94cb1e86 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,29 +24,25 @@ 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.shared.footprint import ( - InstrumentSizeViewModel, - SampleFootprintViewModel, -) +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.specification.sequences import ( - MAX_SEQUENCE_ITEMS, -) +from sampletones_core.formats.famitracker.footprint import InstrumentFootprint +from sampletones_core.formats.famitracker.specification.sequences import MAX_SEQUENCE_ITEMS from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase SEQUENCE_STATUS_KEY: Final[str] = "reconstructions.instruments.message.status_sequence" +LARGEST_PULSE: Final[InstrumentFootprint] = InstrumentFootprint(instrument_bytes=9, sequence_bytes=768) +LARGEST_TRIANGLE: Final[InstrumentFootprint] = InstrumentFootprint(instrument_bytes=7, sequence_bytes=512) +SILENT_INSTRUMENT: Final[InstrumentFootprint] = InstrumentFootprint(instrument_bytes=3, sequence_bytes=0) + NOT_LOADED: Final[ReconstructionInstrumentsViewModel] = ReconstructionInstrumentsViewModel( reconstruction_loaded=False, playing_generators=frozenset(), @@ -55,18 +51,13 @@ def build_view_model( - channel_bytes: Dict[GeneratorName, int], + channel_footprints: Dict[GeneratorName, InstrumentFootprint], ) -> ReconstructionInstrumentsViewModel: - """A loaded reconstruction covering the given channels, each measured at the given size.""" + """A loaded reconstruction playing the given channels, each measured as given.""" return ReconstructionInstrumentsViewModel( reconstruction_loaded=True, - playing_generators=frozenset(channel_bytes), - footprint=SampleFootprintViewModel( - instruments=tuple( - InstrumentSizeViewModel(generator=generator_name, total_bytes=byte_count) - for generator_name, byte_count in channel_bytes.items() - ), - ), + playing_generators=frozenset(channel_footprints), + footprint=SampleFootprintViewModel.from_footprints(channel_footprints), ) @@ -246,27 +237,27 @@ class TestSizeFields(BaseTestSuite): @dataclass(frozen=True, kw_only=True) class SizeCase(BaseRegularTestCase): - channel_bytes: Dict[GeneratorName, int] + channel_footprints: Dict[GeneratorName, InstrumentFootprint] expected: str test_cases = ( SizeCase( label="a single channel spends what its instrument does", - channel_bytes={GeneratorName.PULSE1: 777}, + channel_footprints={GeneratorName.PULSE1: LARGEST_PULSE}, expected="777 B", ), SizeCase( label="three channels spend their instruments together", - channel_bytes={ - GeneratorName.PULSE1: 777, - GeneratorName.TRIANGLE: 519, - GeneratorName.NOISE: 777, + channel_footprints={ + GeneratorName.PULSE1: LARGEST_PULSE, + GeneratorName.TRIANGLE: LARGEST_TRIANGLE, + GeneratorName.NOISE: LARGEST_PULSE, }, expected="2073 B", ), SizeCase( label="a silent channel spends the instrument definition alone", - channel_bytes={GeneratorName.TRIANGLE: 3}, + channel_footprints={GeneratorName.TRIANGLE: SILENT_INSTRUMENT}, expected="3 B", ), ) @@ -279,7 +270,7 @@ def test_the_sample_size_sums_its_channels( shown: Dict[str, bool], case: SizeCase, ) -> None: - panel.update_view(build_view_model(case.channel_bytes)) + panel.update_view(build_view_model(case.channel_footprints)) assert written[panel.sample_size_tag] == case.expected @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label) @@ -290,11 +281,14 @@ def test_each_channel_states_its_own_size( shown: Dict[str, bool], case: SizeCase, ) -> None: - panel.update_view(build_view_model(case.channel_bytes)) + panel.update_view(build_view_model(case.channel_footprints)) assert { generator_name: written[panel._get_instrument_size_tag(generator_name)] - for generator_name in case.channel_bytes - } == {generator_name: f"{byte_count} B" for generator_name, byte_count in case.channel_bytes.items()} + for generator_name in case.channel_footprints + } == { + generator_name: f"{footprint.total_bytes} B" + for generator_name, footprint in case.channel_footprints.items() + } @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label) def test_a_channel_standing_by_costs_nothing( @@ -305,15 +299,15 @@ def test_a_channel_standing_by_costs_nothing( case: SizeCase, ) -> None: """A channel that describes no frame is written by no export, so its tab states what that costs.""" - panel.update_view(build_view_model(case.channel_bytes)) + panel.update_view(build_view_model(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 case.channel_bytes + if generator_name not in case.channel_footprints } == { generator_name: "0 B" for generator_name in GeneratorName.items() - if generator_name not in case.channel_bytes + if generator_name not in case.channel_footprints } @@ -330,7 +324,7 @@ def test_every_channel_keeps_its_tab( written: Dict[str, str], shown: Dict[str, bool], ) -> None: - panel.update_view(build_view_model({GeneratorName.PULSE1: 777})) + panel.update_view(build_view_model({GeneratorName.PULSE1: LARGEST_PULSE})) assert { generator_name: shown[panel._get_generator_tab_tag(generator_name)] for generator_name in GeneratorName.items() @@ -343,7 +337,7 @@ def test_a_channel_standing_by_reads_muted( shown: Dict[str, bool], bound_themes: List[str], ) -> None: - panel.update_view(build_view_model({GeneratorName.PULSE1: 777})) + 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, @@ -360,7 +354,7 @@ def test_only_a_playing_channel_offers_its_export( buttons = {generator_name: MagicMock() for generator_name in GeneratorName.items()} panel._export_buttons.update(cast(Dict[GeneratorName, GUIButton], buttons)) - panel.update_view(build_view_model({GeneratorName.TRIANGLE: 519})) + panel.update_view(build_view_model({GeneratorName.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() @@ -374,7 +368,7 @@ def test_a_loaded_reconstruction_shows_the_sample_size( written: Dict[str, str], shown: Dict[str, bool], ) -> None: - panel.update_view(build_view_model({GeneratorName.PULSE1: 777})) + panel.update_view(build_view_model({GeneratorName.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/sequencer/test_samples_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_samples_menu.py index 9326e8c0..5e9d37db 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 @@ -13,11 +13,9 @@ 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.shared.footprint import ( - InstrumentSizeViewModel, - SampleFootprintViewModel, -) +from sampletones_application.view_model.shared.footprint import SampleFootprintViewModel from sampletones_core.constants.enums import GeneratorName +from sampletones_core.formats.famitracker.footprint import InstrumentFootprint from sampletones_core.utils.display import display_sample_label from tests.suite.shortcuts import shipped_source @@ -34,13 +32,15 @@ SIZE_TEMPLATE = "{bytes} B" DETAIL_COLOR = LiteralColor((0, 0, 0, 255)) -PULSE_1_BYTES = 41 -NOISE_BYTES = 19 -FOOTPRINT = SampleFootprintViewModel( - instruments=( - InstrumentSizeViewModel(generator=GeneratorName.PULSE1, total_bytes=PULSE_1_BYTES), - InstrumentSizeViewModel(generator=GeneratorName.NOISE, total_bytes=NOISE_BYTES), - ), +PULSE_1_FOOTPRINT = InstrumentFootprint(instrument_bytes=9, sequence_bytes=32) +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( + { + GeneratorName.PULSE1: PULSE_1_FOOTPRINT, + GeneratorName.NOISE: NOISE_FOOTPRINT, + } ) EDIT_ITEM = 0 From a8ea490eb7839250529adf78631ed6b6b5354698 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Thu, 13 Aug 2026 16:45:35 +0200 Subject: [PATCH 09/11] Fixed: held-dimension bookkeeping on channels --- docs/formats/reconstructions.md | 10 ++-- src/sampletones_core/exporters/feature.py | 8 ++- src/sampletones_core/features/__init__.py | 2 + src/sampletones_core/features/spec.py | 29 +++++++++-- .../reconstruction/instructions.py | 8 +-- .../reconstruction/reconstruction.py | 32 ++++++------ .../categories/test_pitch.py | 32 +++++++++--- .../exporters/test_feature.py | 7 +++ .../reconstruction/test_reconstruction.py | 49 +++++++++++++++++++ 9 files changed, 141 insertions(+), 36 deletions(-) diff --git a/docs/formats/reconstructions.md b/docs/formats/reconstructions.md index d0fb3174..d91c4adb 100644 --- a/docs/formats/reconstructions.md +++ b/docs/formats/reconstructions.md @@ -42,13 +42,15 @@ A `.stn` file holds: * **per-channel held dimensions** — the envelopes each channel leaves to the player. An instruction states a value for every dimension of its frame, so this 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 freshly - built reconstruction writes them all, and clearing an envelope in 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. 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. A file naming a stream for the channels -it plays alone reads as the whole four, with the rest coming back standing by. +written into it sounds on a mid-range note, and it leaves every dimension it offers +to the player, which is the record a channel edited down to empty envelopes reaches +as well. A file naming a stream for the channels it plays alone reads as the whole +four, with the rest coming back standing by. ## Detached reconstructions diff --git a/src/sampletones_core/exporters/feature.py b/src/sampletones_core/exporters/feature.py index bfd3e2a6..54f1bba4 100644 --- a/src/sampletones_core/exporters/feature.py +++ b/src/sampletones_core/exporters/feature.py @@ -130,10 +130,14 @@ 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 leave_to_channel(self, feature_keys: Iterable[FeatureKey]) -> None: - """Empties the given dimensions' envelopes, so the channel governs them. + """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: - self[feature_key] = np.array([], dtype=np.int8) + if feature_key in self: + self[feature_key] = np.array([], dtype=np.int8) diff --git a/src/sampletones_core/features/__init__.py b/src/sampletones_core/features/__init__.py index 3a9e0af0..d14b4b0c 100644 --- a/src/sampletones_core/features/__init__.py +++ b/src/sampletones_core/features/__init__.py @@ -7,6 +7,7 @@ RESTING_REFERENCE_PITCH, FeatureRange, feature_range, + resting_held_features, resting_reference, supported_features, supports, @@ -21,6 +22,7 @@ "RESTING_REFERENCE_PITCH", "FeatureRange", "feature_range", + "resting_held_features", "resting_reference", "supported_features", "supports", diff --git a/src/sampletones_core/features/spec.py b/src/sampletones_core/features/spec.py index f7a76f2f..dd3c0b0b 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, Tuple +from typing import Dict, Final, List, Tuple from sampletones_core.constants.enums import FeatureKey, GeneratorName, LibraryGeneratorName from sampletones_core.constants.general import ( @@ -87,12 +87,35 @@ def resting_reference(generator_name: GeneratorName) -> int: return RESTING_REFERENCE_PITCH -def supported_features(kind: LibraryGeneratorName) -> list[FeatureKey]: +def resting_held_features( + generator_name: GeneratorName, +) -> Tuple[FeatureKey, ...]: + """The dimensions a channel governs while it describes no frame. + + A stream with no frames writes no dimension, so every dimension the channel offers is the + channel's to hold. Recording them makes a channel that has always stood by read the same as + one edited down to empty envelopes. + + Args: + generator_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])) + + +def supported_features( + kind: LibraryGeneratorName, +) -> List[FeatureKey]: ranges = GENERATOR_FEATURE_RANGES[kind] return [feature for feature in FEATURE_DIMENSION_ORDER if feature in ranges] -def feature_range(kind: LibraryGeneratorName, feature: FeatureKey) -> FeatureRange: +def feature_range( + kind: LibraryGeneratorName, + feature: FeatureKey, +) -> FeatureRange: return GENERATOR_FEATURE_RANGES[kind][feature] diff --git a/src/sampletones_core/reconstructions/reconstruction/instructions.py b/src/sampletones_core/reconstructions/reconstruction/instructions.py index a71b4cf2..7fe2fd89 100644 --- a/src/sampletones_core/reconstructions/reconstruction/instructions.py +++ b/src/sampletones_core/reconstructions/reconstruction/instructions.py @@ -6,7 +6,7 @@ from sampletones_core.constants.enums import FeatureKey, GeneratorName from sampletones_core.data import DataModel -from sampletones_core.features import resting_reference +from sampletones_core.features import resting_held_features, resting_reference from sampletones_core.instructions import InstructionData, InstructionUnion @@ -57,7 +57,9 @@ def resting(cls, generator_name: GeneratorName) -> InstructionsItem: A reconstruction holds one stream per channel, so a channel it leaves silent is present and editable: it rests at the reference its first envelope will sound at, - and describing a frame is what puts it back in play. + and describing a frame is what puts it back in play. Writing no frame leaves every + dimension the channel offers to the channel, which is what an edit clearing the last + frame records and what an export of this stream reads back. Args: generator_name: The channel the resting stream belongs to. @@ -69,5 +71,5 @@ def resting(cls, generator_name: GeneratorName) -> InstructionsItem: generator_name=generator_name, instructions=[], initial_pitch=resting_reference(generator_name), - held_features=(), + held_features=resting_held_features(generator_name), ) diff --git a/src/sampletones_core/reconstructions/reconstruction/reconstruction.py b/src/sampletones_core/reconstructions/reconstruction/reconstruction.py index 4655c608..b066f129 100644 --- a/src/sampletones_core/reconstructions/reconstruction/reconstruction.py +++ b/src/sampletones_core/reconstructions/reconstruction/reconstruction.py @@ -30,7 +30,6 @@ ExporterUnion, Features, ) -from sampletones_core.features import resting_reference from sampletones_core.generators.maps import GENERATOR_CLASSES from sampletones_core.instructions import InstructionUnion from sampletones_shared.application import SAMPLETONES_RECONSTRUCTION_DATA_VERSION @@ -167,19 +166,11 @@ def _exporter_class( return cls._get_exporter_class(instructions[0]) @classmethod - def _derive_initial_pitch( - cls, - generator_name: GeneratorName, - instructions: List[InstructionUnion], - ) -> int: - """Chooses the reference pitch a channel's arpeggio envelope is measured against. + def _derive_initial_pitch(cls, instructions: List[InstructionUnion]) -> int: + """Chooses the reference pitch the arpeggio envelope of a channel in play is measured against. - The instruction type selects the exporter, matching how `export` resolves one. A - channel describing no frame rests at the reference its first envelope will sound at. + The instruction type selects the exporter, matching how `export` resolves one. """ - if not instructions: - return resting_reference(generator_name) - exporter_class = cls._get_exporter_class(instructions[0]) return exporter_class.derive_initial_pitch(instructions) # type: ignore[arg-type] @@ -195,21 +186,26 @@ def create( ) -> Self: approximation = np.nan_to_num(approximation, nan=0.0) approximations_data: List[ApproximationsItem] = [ - ApproximationsItem(generator_name=name, approximation=approximation) - for name, approximation in approximations.items() + ApproximationsItem( + generator_name=generator_name, + approximation=approximations[generator_name], + ) + for generator_name in GeneratorName.items() + if generator_name in approximations ] instructions_data: List[InstructionsItem] = [] for generator_name in GeneratorName.items(): channel_instructions = list(instructions.get(generator_name, ())) + if not channel_instructions: + instructions_data.append(InstructionsItem.resting(generator_name)) + continue + instructions_data.append( InstructionsItem.create( generator_name=generator_name, instructions=channel_instructions, - initial_pitch=cls._derive_initial_pitch( - generator_name, - channel_instructions, - ), + initial_pitch=cls._derive_initial_pitch(channel_instructions), held_features=(), ) ) diff --git a/tests/unit/sampletones_application/categories/test_pitch.py b/tests/unit/sampletones_application/categories/test_pitch.py index e1c317f8..2d1ef7ee 100644 --- a/tests/unit/sampletones_application/categories/test_pitch.py +++ b/tests/unit/sampletones_application/categories/test_pitch.py @@ -1,7 +1,7 @@ import pytest from sampletones_application.categories.manager import LanguageManager -from sampletones_application.categories.pitch import build_pitch_tooltip +from sampletones_application.categories.pitch import PitchTooltips from sampletones_application.paths import LANG_EN from sampletones_core.utils.pitch_kind import PERIOD_VALUE_KIND, PITCH_VALUE_KIND @@ -13,23 +13,43 @@ def language_manager() -> LanguageManager: class TestBuildPitchTooltip: def test_fills_every_template_placeholder(self, language_manager: LanguageManager) -> None: - tooltip = build_pitch_tooltip(language_manager, PITCH_VALUE_KIND, "{}/{}/{}") + tooltip = PitchTooltips.build_pitch_tooltip(language_manager, PITCH_VALUE_KIND, "{}/{}/{}") assert "{}" not in tooltip assert len(tooltip.split("/")) == 3 def test_example_value_agrees_with_example_name(self, language_manager: LanguageManager) -> None: - _type_name, example_name, example_value = build_pitch_tooltip( + _type_name, example_name, example_value = PitchTooltips.build_pitch_tooltip( language_manager, PITCH_VALUE_KIND, "{}|{}|{}" ).split("|") assert PITCH_VALUE_KIND.to_name(int(example_value)) == example_name def test_period_example_value_agrees_with_example_name(self, language_manager: LanguageManager) -> None: - _type_name, example_name, example_value = build_pitch_tooltip( + _type_name, example_name, example_value = PitchTooltips.build_pitch_tooltip( language_manager, PERIOD_VALUE_KIND, "{}|{}|{}" ).split("|") assert PERIOD_VALUE_KIND.to_name(int(example_value)) == example_name def test_pitch_and_period_name_the_quantity_differently(self, language_manager: LanguageManager) -> None: - pitch_type = build_pitch_tooltip(language_manager, PITCH_VALUE_KIND, "{}") - period_type = build_pitch_tooltip(language_manager, PERIOD_VALUE_KIND, "{}") + pitch_type = PitchTooltips.build_pitch_tooltip(language_manager, PITCH_VALUE_KIND, "{}") + period_type = PitchTooltips.build_pitch_tooltip(language_manager, PERIOD_VALUE_KIND, "{}") assert pitch_type != period_type + + +class TestPitchTooltips: + """A panel phrases both readings once and picks the one each field takes.""" + + def test_a_field_reads_the_help_its_kind_names(self, language_manager: LanguageManager) -> None: + tooltips = PitchTooltips.build(language_manager, "{}|{}|{}") + + assert tooltips.for_kind(PITCH_VALUE_KIND) == tooltips.pitch + assert tooltips.for_kind(PERIOD_VALUE_KIND) == tooltips.period + + def test_the_two_readings_phrase_the_same_template_differently( + self, + language_manager: LanguageManager, + ) -> None: + tooltips = PitchTooltips.build(language_manager, "{}|{}|{}") + + assert tooltips.pitch != tooltips.period + assert "{}" not in tooltips.pitch + assert "{}" not in tooltips.period diff --git a/tests/unit/sampletones_core/exporters/test_feature.py b/tests/unit/sampletones_core/exporters/test_feature.py index ce4501e9..3c4372c3 100644 --- a/tests/unit/sampletones_core/exporters/test_feature.py +++ b/tests/unit/sampletones_core/exporters/test_feature.py @@ -50,3 +50,10 @@ def test_leaving_a_dimension_to_the_channel_empties_its_envelope(self) -> None: assert features.volume.size == 0 assert features.duty_cycle is not None and features.duty_cycle.size == 0 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)) + assert features.duty_cycle is None + assert features.held_features == (FeatureKey.VOLUME,) diff --git a/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py b/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py index 3879dad4..13b27c77 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py @@ -12,6 +12,7 @@ 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_shared.application import ( SAMPLETONES_RECONSTRUCTION_DATA_VERSION, ) @@ -335,6 +336,54 @@ def test_the_written_dimensions_export_their_items(self) -> None: assert features.duty_cycle is not None assert features.duty_cycle.size > 0 + 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. + + The record is the only place an empty envelope's meaning is kept, so a channel in play + and one standing by both have to state the dimensions their export leaves empty. + """ + reconstruction = _reconstruction([_pulse(_BASE_PITCH)] * 3) + + reconstruction.update_generator_data( + GeneratorName.PULSE1, + [_pulse(_BASE_PITCH)] * 3, + np.ones(_AUDIO_LENGTH, dtype=np.float32), + _BASE_PITCH, + (FeatureKey.ARPEGGIO,), + ) + + exported = reconstruction.export() + assert reconstruction.held_features == { + generator_name: features.held_features for generator_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] == ( + FeatureKey.VOLUME, + FeatureKey.ARPEGGIO, + ) + assert reconstruction.held_features[GeneratorName.NOISE] == ( + FeatureKey.VOLUME, + FeatureKey.ARPEGGIO, + FeatureKey.DUTY_CYCLE, + ) + + 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, + [], + np.zeros(0, dtype=np.float32), + resting_reference(GeneratorName.PULSE1), + (FeatureKey.VOLUME, FeatureKey.ARPEGGIO, FeatureKey.DUTY_CYCLE), + ) + + assert reconstruction.streams[GeneratorName.PULSE1] == InstructionsItem.resting(GeneratorName.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( From a8c0bbfa824fc9fa307ba9c1585f4b9579046ecb Mon Sep 17 00:00:00 2001 From: JakimPL Date: Thu, 13 Aug 2026 20:26:53 +0200 Subject: [PATCH 10/11] Documented: the tracker byte counter --- docs/guide/interface.md | 9 ++--- docs/guide/sequencer.md | 3 +- .../categories/context.py | 34 +++++++++++++++---- .../categories/elements/global_.py | 3 ++ .../ui/elements/context_menu.py | 7 +++- .../reconstruction/instruments/instruments.py | 22 +++++++++--- .../ui/panels/sequencer/samples.py | 8 +++-- src/sampletones_config/lang/en.yaml | 1 + .../ui/panels/sequencer/test_samples_menu.py | 18 ++++++++++ 9 files changed, 85 insertions(+), 20 deletions(-) diff --git a/docs/guide/interface.md b/docs/guide/interface.md index 69742a96..4f202762 100644 --- a/docs/guide/interface.md +++ b/docs/guide/interface.md @@ -54,10 +54,11 @@ instrument — its pitch, volume, arpeggio, and duty sequences — which you can 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. -**Export instrument...** writes the channel -on show, for whichever tracker the save dialog's file type names — see -[where your files live](files.md#exported-files). +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 +live](files.md#exported-files). ## Instructions diff --git a/docs/guide/sequencer.md b/docs/guide/sequencer.md index 20c3502e..8c976f6c 100644 --- a/docs/guide/sequencer.md +++ b/docs/guide/sequencer.md @@ -21,7 +21,8 @@ Manage the imported samples in the **Samples** list on the right: right-click on 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. +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. diff --git a/src/sampletones_application/categories/context.py b/src/sampletones_application/categories/context.py index 30a98bcb..820d60a3 100644 --- a/src/sampletones_application/categories/context.py +++ b/src/sampletones_application/categories/context.py @@ -13,24 +13,46 @@ } -def context_label( +def context_text( language_manager: LanguageManager, + text_type: TextType, element: ContextElements, ) -> str: - """Resolves a context-action label, the words every menu offering that action prints. + """Resolves one reading of a context element: its label, the template it fills or its tooltip. - Cut, Copy and Play name one gesture wherever they are offered, so the cell menus of the - sequencer grids, the file trees and the menu bar read them from one entry. A reader then - meets the same word for the same action, and a translation reaches all of them at once. + A context element is stated once and read in several voices — the byte figures name a size + with a label, print it through a template and explain it in a tooltip — so every voice of an + element comes from the same place. + + Args: + language_manager: The catalogue 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. """ return language_manager[ Page.GLOBAL, Panel.CONTEXT, - TextType.LABEL, + text_type, element, ] +def context_label( + language_manager: LanguageManager, + element: ContextElements, +) -> str: + """Resolves a context-action label, the words every menu offering that action prints. + + Cut, Copy and Play name one gesture wherever they are offered, so the cell menus of the + sequencer grids, the file trees and the menu bar read them from one entry. A reader then + meets the same word for the same action, and a translation reaches all of them at once. + """ + return context_text(language_manager, TextType.LABEL, element) + + def channel_label( language_manager: LanguageManager, generator: GeneratorName, diff --git a/src/sampletones_application/categories/elements/global_.py b/src/sampletones_application/categories/elements/global_.py index f41b3494..7d1fbec7 100644 --- a/src/sampletones_application/categories/elements/global_.py +++ b/src/sampletones_application/categories/elements/global_.py @@ -49,6 +49,9 @@ class ContextElements(AbstractElement): PULSE_1 = "pulse_1" PULSE_2 = "pulse_2" NOISE = "noise" + SAMPLE_SIZE = "sample_size" + INSTRUMENT_SIZE = "instrument_size" + SIZE_BYTES = "size_bytes" class NodeDetailElements(AbstractElement): diff --git a/src/sampletones_application/ui/elements/context_menu.py b/src/sampletones_application/ui/elements/context_menu.py index 0e456f6d..e63e62f5 100644 --- a/src/sampletones_application/ui/elements/context_menu.py +++ b/src/sampletones_application/ui/elements/context_menu.py @@ -1,11 +1,12 @@ import contextlib -from typing import Iterator, Sequence, Tuple +from typing import Iterator, Optional, Sequence, Tuple import dearpygui.dearpygui as dpg 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 @@ -53,6 +54,7 @@ def add_detail_items( items: Sequence[Tuple[str, str]], *, color: BaseColor, + tooltip: Optional[str] = None, ) -> None: """Add a block of read-only ``label: value`` lines to the context menu being built. @@ -64,6 +66,7 @@ def add_detail_items( Args: items: The label and value of each line, in the order the menu prints them. color: The tint the lines take, which marks them as facts rather than actions. + tooltip: An explanation the whole block shares, reached by hovering any of its lines. """ if not items: return @@ -73,3 +76,5 @@ def add_detail_items( detail_text = dpg.add_text(f"{label}: {value}") dpg_set_palette_color(detail_text, color) FontRegistry.bind_to_item(detail_text, Font.MONO_SMALL) + if tooltip is not None: + show_tooltip(detail_text, tooltip) diff --git a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py index c4b31304..c33aa6d5 100644 --- a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py +++ b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py @@ -4,7 +4,9 @@ import dearpygui.dearpygui as dpg import numpy as np -from sampletones_application.categories.context import channel_label +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.categories.pitch import PitchTooltips from sampletones_application.layout.general.colors.feature import FeatureColors @@ -15,6 +17,7 @@ SUF_GROUP, SUF_HANDLER_REGISTRY, SUF_TEXT, + SUF_TOOLTIP, TAG_GLOBAL_THEME_DEFAULT, TAG_GLOBAL_THEME_INPUT_INVALID, TAG_GLOBAL_THEME_INPUT_WARNING, @@ -60,6 +63,7 @@ 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.view_model.reconstruction.instruments import ( ReconstructionInstrumentsViewModel, ) @@ -135,9 +139,10 @@ def __init__( self.on_raw_data_changed: Optional[Callable[[GeneratorName, FeatureKey, np.ndarray], None]] = None self._lbl_copy = language_manager["reconstructions.instruments.label.copy_button"] - self._lbl_sample_size = language_manager["global.context.label.sample_size"] - self._lbl_instrument_size = language_manager["global.context.label.instrument_size"] - self._tpl_size_bytes = language_manager["global.context.template.size_bytes"] + self._lbl_sample_size = context_label(language_manager, ContextElements.SAMPLE_SIZE) + self._lbl_instrument_size = context_label(language_manager, ContextElements.INSTRUMENT_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._pitch_tooltips = PitchTooltips.build( language_manager, language_manager["reconstructions.instruments.template.initial_pitch_tooltip_template"], @@ -209,7 +214,8 @@ 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. + below it, and the value carries the stepper's own read-only colour and font. A tooltip + names the export the figure measures, since the formats spend differently. """ with labeled_field( label, @@ -220,6 +226,12 @@ def _create_size_field( dpg_set_palette_color(value_tag, self._pitch_stepper_style.value_color) FontRegistry.bind_to_item(value_tag, Font.MONO) + show_tooltip( + value_tag, + self._tip_size_bytes, + 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) diff --git a/src/sampletones_application/ui/panels/sequencer/samples.py b/src/sampletones_application/ui/panels/sequencer/samples.py index dc89d966..6814c5b0 100644 --- a/src/sampletones_application/ui/panels/sequencer/samples.py +++ b/src/sampletones_application/ui/panels/sequencer/samples.py @@ -3,7 +3,7 @@ import dearpygui.dearpygui as dpg -from sampletones_application.categories.context import channel_label, context_label +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, @@ -114,8 +114,9 @@ def __init__( self._selected_row: Optional[int] = None self._editing_sample_id: Optional[str] = None self._entries: Tuple[SampleEntryViewModel, ...] = () - self._lbl_sample_size = language_manager["global.context.label.sample_size"] - self._tpl_size_bytes = language_manager["global.context.template.size_bytes"] + 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.sample_footprint: Optional[Callable[[str], Optional[SampleFootprintViewModel]]] = None self.on_sample_selected: Optional[StringCallback] = None self.on_sample_edit_requested: Optional[StringCallback] = None @@ -557,6 +558,7 @@ def _show_context_menu(self, position: int, sample_id: str) -> None: add_detail_items( self._footprint_items(sample_id), color=self._detail_color, + tooltip=self._tip_size_bytes, ) dpg.add_separator() add_play_menu_item( diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index d91fe3ac..6c8471ef 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -160,6 +160,7 @@ global.context.label.detail_configuration: "Configuration" global.context.label.instrument_size: "Instrument size" global.context.label.sample_size: "Sample size" global.context.template.size_bytes: "{bytes} B" +global.context.tooltip.size_bytes: "How many bytes this takes as a FamiTracker instrument." # ============================================================================= # Global — Menu 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 5e9d37db..9a850400 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 @@ -30,6 +30,7 @@ SAMPLE_SIZE_LABEL = "Sample size" SIZE_TEMPLATE = "{bytes} B" +SIZE_TOOLTIP = "Bytes a FamiTracker export spends." DETAIL_COLOR = LiteralColor((0, 0, 0, 255)) PULSE_1_FOOTPRINT = InstrumentFootprint(instrument_bytes=9, sequence_bytes=32) @@ -129,6 +130,7 @@ def _panel( 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 requests = Requests() @@ -160,11 +162,16 @@ class _MenuBuildRecorder: def __init__(self) -> None: self.widgets: List[MenuWidget] = [] + self.tooltips: List[str] = [] def add_text(self, text: str, **_kwargs: Any) -> int: self.widgets.append(MenuWidget(kind="text", text=text)) return 0 + def add_tooltip(self, _parent: int, message: str, **_kwargs: Any) -> int: + self.tooltips.append(message) + return 0 + def add_separator(self, **_kwargs: Any) -> int: self.widgets.append(MenuWidget(kind="separator", text="")) return 0 @@ -198,6 +205,7 @@ def build_recorder(monkeypatch: pytest.MonkeyPatch) -> _MenuBuildRecorder: monkeypatch.setattr(samples_module.dpg, "add_menu_item", recorded.add_menu_item) monkeypatch.setattr(samples_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) return recorded @@ -337,6 +345,16 @@ def test_the_sizes_sit_between_the_sample_name_and_the_actions( f"{ContextElements.NOISE.value}: {NOISE_BYTES} B", ] + def test_every_figure_names_the_export_it_measures( + self, + monkeypatch: pytest.MonkeyPatch, + build_recorder: _MenuBuildRecorder, + ) -> None: + """A byte count means one export, so each line a reader hovers says which one it counts.""" + _panel(monkeypatch).panel._show_context_menu(SELECTED_ROW, SELECTED_ID) + + assert build_recorder.tooltips == [SIZE_TOOLTIP] * 3 + def test_a_menu_with_no_figures_reads_as_it_always_has( self, monkeypatch: pytest.MonkeyPatch, From bbeb67fedc80fba5a964a78366dc807ad55157fc Mon Sep 17 00:00:00 2001 From: JakimPL Date: Thu, 13 Aug 2026 21:02:53 +0200 Subject: [PATCH 11/11] Added: coverage for cleared envelopes and per-channel held dimensions --- docs/development/guidelines.md | 2 +- tests/suite/sequencer.py | 55 +++++-- .../logic/sequencer/playback/test_voice.py | 141 +++++++++++++++--- .../logic/sequencer/test_samples.py | 15 ++ .../services/test_regeneration.py | 79 ++++++++++ .../reconstruction/test_instruments_panel.py | 51 +++---- .../formats/famitracker/test_footprint.py | 38 ++--- .../reconstruction/test_reconstruction.py | 31 ++++ 8 files changed, 331 insertions(+), 81 deletions(-) diff --git a/docs/development/guidelines.md b/docs/development/guidelines.md index 313d3d53..7c72f87a 100644 --- a/docs/development/guidelines.md +++ b/docs/development/guidelines.md @@ -86,7 +86,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. 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. Inherit from `BaseTestSuite` and `BaseTestCase`. +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. Do not assert default values of configurations, layouts, settings, and similar. Defaults are not contracts, and pinning them overconstrains the tests. Test behavior instead: validation bounds, serialization round-trips, and invariants. The exception is when values must match by contract rather than equal a chosen constant — e.g. project metadata at creation or after a save/load round-trip should be asserted to match, never hardcoded to a version string. diff --git a/tests/suite/sequencer.py b/tests/suite/sequencer.py index 36551400..ff4b13f7 100644 --- a/tests/suite/sequencer.py +++ b/tests/suite/sequencer.py @@ -12,7 +12,12 @@ 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.instructions import PulseInstruction +from sampletones_core.instructions import ( + InstructionUnion, + NoiseInstruction, + 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 @@ -26,6 +31,10 @@ from sampletones_shared.constants.symbols import MINUS, MIXED, PLUS SAMPLE_LENGTH: Final[int] = 64 +SAMPLE_PITCH: Final[int] = 60 +SAMPLE_VOLUME: Final[int] = 8 +SAMPLE_PERIOD: Final[int] = 4 +SAMPLE_DUTY_CYCLE: Final[int] = 0 COLUMN_SEPARATOR: Final[str] = "|" UNKNOWN_SAMPLE: Final[str] = "!!" UNKNOWN_SAMPLE_ID: Final[str] = "a-sample-no-project-holds" @@ -37,18 +46,12 @@ def sample_reconstruction(generators: Sequence[GeneratorName]) -> Reconstruction 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 + what names the exporter a channel is read through — so a reading taken off this reconstruction + is the reading the channel gives. """ - instructions = { - generator: [ - PulseInstruction( - on=True, - pitch=60, - volume=8, - duty_cycle=0, - ) - ] - for generator in generators - } + instructions = {generator: [_instruction(generator)] for generator in generators} approximations = {generator: np.zeros(SAMPLE_LENGTH, dtype=np.float32) for generator in generators} return Reconstruction.create( approximation=np.zeros(SAMPLE_LENGTH, dtype=np.float32), @@ -257,6 +260,34 @@ def parse_volume(token: str) -> Optional[int]: return int(token, 16) +def _instruction(generator: GeneratorName) -> InstructionUnion: + """The instruction a channel sounds, which is the type its generator 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: + return TriangleInstruction( + on=True, + pitch=SAMPLE_PITCH, + ) + case GeneratorName.NOISE: + return NoiseInstruction( + on=True, + period=SAMPLE_PERIOD, + volume=SAMPLE_VOLUME, + short=False, + ) + case _: + return PulseInstruction( + on=True, + pitch=SAMPLE_PITCH, + volume=SAMPLE_VOLUME, + duty_cycle=SAMPLE_DUTY_CYCLE, + ) + + def _fill_cell( tracker_logic: SequencerTrackerLogic, row_index: int, 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 be4cb06b..e7b1778e 100644 --- a/tests/unit/sampletones_application/logic/sequencer/playback/test_voice.py +++ b/tests/unit/sampletones_application/logic/sequencer/playback/test_voice.py @@ -25,6 +25,9 @@ REFERENCE_PERIOD: Final[int] = 4 SAMPLE_VOLUME: Final[int] = 9 CHANNEL_VOLUME: Final[int] = 4 +CHANNEL_ARPEGGIO: Final[int] = 7 +CHANNEL_DUTY_CYCLE: Final[int] = 1 +CHANNEL_LONG_MODE: Final[int] = 0 DUTY_CYCLE: Final[int] = 2 @@ -129,8 +132,14 @@ def test_the_channel_takes_up_what_the_instrument_writes(self, test_case: TestCa assert values[FeatureKey.VOLUME] == (MAX_VOLUME if test_case.label == "triangle" else SAMPLE_VOLUME) -class TestAHeldDimensionSoundsAtTheChannelsValue: - """A dimension the instrument leaves empty is the channel's, so it sounds at the value it holds.""" +class TestAHeldDimensionSoundsAtTheChannelsValue(BaseTestSuite): + """A dimension the instrument leaves empty is the channel's, so it sounds at the value it holds. + + Each channel offers its own dimensions and spells them in its own terms — an arpeggio is a + pitch on pulse and triangle and a period on noise, and a duty cycle is a waveform on pulse and + the noise mode on noise — so every dimension a channel offers is held here in the terms that + channel reads it in. + """ _INSTRUCTION = PulseInstruction( on=True, @@ -139,29 +148,125 @@ class TestAHeldDimensionSoundsAtTheChannelsValue: duty_cycle=DUTY_CYCLE, ) - def test_the_channels_level_carries_over_the_frame(self) -> None: - voice = _voice(GeneratorName.PULSE1, [self._INSTRUCTION], (FeatureKey.VOLUME,)) - values = _channel_values() - values[FeatureKey.VOLUME] = CHANNEL_VOLUME + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + generator_name: GeneratorName + instruction: InstructionUnion + held_feature: FeatureKey + channel_value: int + expected: InstructionUnion - assert voice.sound(self._INSTRUCTION, values).volume == CHANNEL_VOLUME + test_cases = ( + TestCase( + label="pulse volume", + generator_name=GeneratorName.PULSE1, + instruction=_INSTRUCTION, + held_feature=FeatureKey.VOLUME, + channel_value=CHANNEL_VOLUME, + expected=PulseInstruction( + on=True, + pitch=REFERENCE_PITCH, + volume=CHANNEL_VOLUME, + duty_cycle=DUTY_CYCLE, + ), + ), + TestCase( + label="pulse arpeggio", + generator_name=GeneratorName.PULSE1, + instruction=_INSTRUCTION, + held_feature=FeatureKey.ARPEGGIO, + channel_value=CHANNEL_ARPEGGIO, + expected=PulseInstruction( + on=True, + pitch=REFERENCE_PITCH + CHANNEL_ARPEGGIO, + volume=SAMPLE_VOLUME, + duty_cycle=DUTY_CYCLE, + ), + ), + TestCase( + label="pulse duty cycle", + generator_name=GeneratorName.PULSE1, + instruction=_INSTRUCTION, + held_feature=FeatureKey.DUTY_CYCLE, + channel_value=CHANNEL_DUTY_CYCLE, + expected=PulseInstruction( + on=True, + pitch=REFERENCE_PITCH, + volume=SAMPLE_VOLUME, + duty_cycle=CHANNEL_DUTY_CYCLE, + ), + ), + TestCase( + label="triangle arpeggio", + generator_name=GeneratorName.TRIANGLE, + instruction=TriangleInstruction(on=True, pitch=REFERENCE_PITCH), + held_feature=FeatureKey.ARPEGGIO, + channel_value=CHANNEL_ARPEGGIO, + expected=TriangleInstruction(on=True, pitch=REFERENCE_PITCH + CHANNEL_ARPEGGIO), + ), + TestCase( + label="noise period", + generator_name=GeneratorName.NOISE, + instruction=NoiseInstruction( + on=True, + period=REFERENCE_PERIOD, + volume=SAMPLE_VOLUME, + short=True, + ), + held_feature=FeatureKey.ARPEGGIO, + channel_value=CHANNEL_ARPEGGIO, + expected=NoiseInstruction( + on=True, + period=REFERENCE_PERIOD + CHANNEL_ARPEGGIO, + volume=SAMPLE_VOLUME, + short=True, + ), + ), + TestCase( + label="noise mode", + generator_name=GeneratorName.NOISE, + instruction=NoiseInstruction( + on=True, + period=REFERENCE_PERIOD, + volume=SAMPLE_VOLUME, + short=True, + ), + held_feature=FeatureKey.DUTY_CYCLE, + channel_value=CHANNEL_LONG_MODE, + expected=NoiseInstruction( + on=True, + period=REFERENCE_PERIOD, + volume=SAMPLE_VOLUME, + short=False, + ), + ), + ) - def test_a_held_dimension_leaves_the_channels_value_where_it_stands(self) -> None: - voice = _voice(GeneratorName.PULSE1, [self._INSTRUCTION], (FeatureKey.VOLUME,)) + @pytest.mark.parametrize( + "test_case", + test_cases, + 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,)) values = _channel_values() - values[FeatureKey.VOLUME] = CHANNEL_VOLUME - - voice.sound(self._INSTRUCTION, values) + values[test_case.held_feature] = test_case.channel_value - assert values[FeatureKey.VOLUME] == CHANNEL_VOLUME + assert voice.sound(test_case.instruction, values) == test_case.expected - def test_the_dimensions_the_instrument_writes_still_sound_its_own(self) -> None: - voice = _voice(GeneratorName.PULSE1, [self._INSTRUCTION], (FeatureKey.VOLUME,)) + @pytest.mark.parametrize( + "test_case", + test_cases, + 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,)) + values = _channel_values() + values[test_case.held_feature] = test_case.channel_value - sounded = voice.sound(self._INSTRUCTION, _channel_values()) + voice.sound(test_case.instruction, values) - assert sounded.pitch == REFERENCE_PITCH - assert sounded.duty_cycle == DUTY_CYCLE + assert values[test_case.held_feature] == test_case.channel_value 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.""" diff --git a/tests/unit/sampletones_application/logic/sequencer/test_samples.py b/tests/unit/sampletones_application/logic/sequencer/test_samples.py index d4c8c5b5..f0a23eca 100644 --- a/tests/unit/sampletones_application/logic/sequencer/test_samples.py +++ b/tests/unit/sampletones_application/logic/sequencer/test_samples.py @@ -208,6 +208,21 @@ def test_a_looping_sample_costs_less_than_a_one_shot( 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. + + The triangle states a pitch alone where the pulse states a level and a waveform too, so + 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") + + footprint = logic.build_sample_footprint(sample.id) + + assert footprint is not None + assert footprint.bytes_for(GeneratorName.TRIANGLE) < footprint.bytes_for(GeneratorName.PULSE1) + def test_a_sample_the_pool_has_dropped_is_measured_nowhere(self) -> None: _, logic = _logic() diff --git a/tests/unit/sampletones_application/services/test_regeneration.py b/tests/unit/sampletones_application/services/test_regeneration.py index 26f92df0..ede8ea03 100644 --- a/tests/unit/sampletones_application/services/test_regeneration.py +++ b/tests/unit/sampletones_application/services/test_regeneration.py @@ -14,6 +14,8 @@ ) from sampletones_core.constants.enums import FeatureKey, GeneratorName from sampletones_core.exporters import Features +from sampletones_core.reconstructions import Reconstruction +from tests.conftest import ReconstructionFactory REFERENCE_PITCH: Final[int] = 60 @@ -366,6 +368,83 @@ def test_run_exception_does_not_update_reconstruction( reconstruction.update_generator_data.assert_not_called() +class TestClearingEveryEnvelope: + """An instrument left with no envelope at all describes no frame, so its channel stands by. + + This is the edit the instruments panel offers on the last dimension an instrument writes, and + it runs the whole way through the service: the exporter produces no instruction, the render + produces no audio, and the reconstruction that comes back holds the channel without playing it. + """ + + @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.leave_to_channel([FeatureKey.ARPEGGIO, FeatureKey.DUTY_CYCLE]) + service = RegenerationService() + results: List[Any] = [] + service.subscribe(results.append) + + service._run( + reconstruction, + GeneratorName.PULSE1, + features, + FeatureKey.VOLUME, + np.array([], dtype=np.int8), + ) + + assert isinstance(results[0], ServiceSuccess) + regenerated: Reconstruction = results[0].value.reconstruction + return regenerated + + def test_a_cleared_instrument_takes_its_channel_out_of_play( + self, + reconstruction_factory: ReconstructionFactory, + ) -> None: + reconstruction = reconstruction_factory() + + regenerated = self._regenerated(reconstruction) + + assert regenerated.instructions[GeneratorName.PULSE1] == [] + assert regenerated.playing_generators == () + + def test_a_cleared_instrument_sounds_as_an_empty_waveform( + self, + reconstruction_factory: ReconstructionFactory, + ) -> None: + reconstruction = reconstruction_factory() + + regenerated = self._regenerated(reconstruction) + + assert regenerated.approximations == {} + assert regenerated.approximation.size == 0 + + def test_the_cleared_channel_records_every_dimension_as_the_channels( + self, + reconstruction_factory: ReconstructionFactory, + ) -> None: + reconstruction = reconstruction_factory() + + regenerated = self._regenerated(reconstruction) + + assert regenerated.held_features[GeneratorName.PULSE1] == ( + FeatureKey.VOLUME, + FeatureKey.ARPEGGIO, + FeatureKey.DUTY_CYCLE, + ) + assert not regenerated.export()[GeneratorName.PULSE1].has_frames + + def test_the_reconstruction_the_edit_was_made_from_keeps_playing( + self, + reconstruction_factory: ReconstructionFactory, + ) -> None: + reconstruction = reconstruction_factory() + + self._regenerated(reconstruction) + + assert reconstruction.playing_generators == (GeneratorName.PULSE1,) + + class TestRegenerationServiceCancellationConstraints: """Tests that document the non-preemptive cancellation behaviour. 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 94cb1e86..9b910424 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 @@ -90,7 +90,7 @@ def bound_themes(monkeypatch: pytest.MonkeyPatch) -> List[str]: return tags -@pytest.fixture +@pytest.fixture(autouse=True) def written(monkeypatch: pytest.MonkeyPatch) -> Dict[str, str]: """Records the texts written to items, standing in for the DPG values.""" values: Dict[str, str] = {} @@ -98,7 +98,7 @@ def written(monkeypatch: pytest.MonkeyPatch) -> Dict[str, str]: return values -@pytest.fixture +@pytest.fixture(autouse=True) def shown(monkeypatch: pytest.MonkeyPatch) -> Dict[str, bool]: """Records which items the panel shows, standing in for the DPG configuration.""" flags: Dict[str, bool] = {} @@ -236,17 +236,17 @@ class TestSizeFields(BaseTestSuite): """The two read-only byte figures: the sample's above the tabs, each channel's inside its tab.""" @dataclass(frozen=True, kw_only=True) - class SizeCase(BaseRegularTestCase): + class TestCase(BaseRegularTestCase): channel_footprints: Dict[GeneratorName, InstrumentFootprint] expected: str test_cases = ( - SizeCase( + TestCase( label="a single channel spends what its instrument does", channel_footprints={GeneratorName.PULSE1: LARGEST_PULSE}, expected="777 B", ), - SizeCase( + TestCase( label="three channels spend their instruments together", channel_footprints={ GeneratorName.PULSE1: LARGEST_PULSE, @@ -255,59 +255,56 @@ class SizeCase(BaseRegularTestCase): }, expected="2073 B", ), - SizeCase( + TestCase( label="a silent channel spends the instrument definition alone", channel_footprints={GeneratorName.TRIANGLE: SILENT_INSTRUMENT}, expected="3 B", ), ) - @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label) + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) def test_the_sample_size_sums_its_channels( self, panel: GUIReconstructionInstrumentsPanel, written: Dict[str, str], - shown: Dict[str, bool], - case: SizeCase, + test_case: TestCase, ) -> None: - panel.update_view(build_view_model(case.channel_footprints)) - assert written[panel.sample_size_tag] == case.expected + panel.update_view(build_view_model(test_case.channel_footprints)) + assert written[panel.sample_size_tag] == test_case.expected - @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label) + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) def test_each_channel_states_its_own_size( self, panel: GUIReconstructionInstrumentsPanel, written: Dict[str, str], - shown: Dict[str, bool], - case: SizeCase, + test_case: TestCase, ) -> None: - panel.update_view(build_view_model(case.channel_footprints)) + 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 case.channel_footprints + for generator_name in test_case.channel_footprints } == { generator_name: f"{footprint.total_bytes} B" - for generator_name, footprint in case.channel_footprints.items() + for generator_name, footprint in test_case.channel_footprints.items() } - @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label) + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) def test_a_channel_standing_by_costs_nothing( self, panel: GUIReconstructionInstrumentsPanel, written: Dict[str, str], - shown: Dict[str, bool], - case: SizeCase, + test_case: TestCase, ) -> None: """A channel that describes no frame is written by no export, so its tab states what that costs.""" - panel.update_view(build_view_model(case.channel_footprints)) + 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 case.channel_footprints + if generator_name not in test_case.channel_footprints } == { generator_name: "0 B" for generator_name in GeneratorName.items() - if generator_name not in case.channel_footprints + if generator_name not in test_case.channel_footprints } @@ -321,7 +318,6 @@ class TestPlayingChannels: def test_every_channel_keeps_its_tab( self, panel: GUIReconstructionInstrumentsPanel, - written: Dict[str, str], shown: Dict[str, bool], ) -> None: panel.update_view(build_view_model({GeneratorName.PULSE1: LARGEST_PULSE})) @@ -333,8 +329,6 @@ def test_every_channel_keeps_its_tab( def test_a_channel_standing_by_reads_muted( self, panel: GUIReconstructionInstrumentsPanel, - written: Dict[str, str], - shown: Dict[str, bool], bound_themes: List[str], ) -> None: panel.update_view(build_view_model({GeneratorName.PULSE1: LARGEST_PULSE})) @@ -348,8 +342,6 @@ def test_a_channel_standing_by_reads_muted( def test_only_a_playing_channel_offers_its_export( self, panel: GUIReconstructionInstrumentsPanel, - written: Dict[str, str], - shown: Dict[str, bool], ) -> None: buttons = {generator_name: MagicMock() for generator_name in GeneratorName.items()} panel._export_buttons.update(cast(Dict[GeneratorName, GUIButton], buttons)) @@ -365,7 +357,6 @@ class TestSizeVisibility: def test_a_loaded_reconstruction_shows_the_sample_size( self, panel: GUIReconstructionInstrumentsPanel, - written: Dict[str, str], shown: Dict[str, bool], ) -> None: panel.update_view(build_view_model({GeneratorName.PULSE1: LARGEST_PULSE})) @@ -374,7 +365,6 @@ def test_a_loaded_reconstruction_shows_the_sample_size( def test_no_reconstruction_hides_the_sample_size( self, panel: GUIReconstructionInstrumentsPanel, - written: Dict[str, str], shown: Dict[str, bool], ) -> None: panel.update_view(NOT_LOADED) @@ -384,7 +374,6 @@ def test_no_reconstruction_states_no_figures( self, panel: GUIReconstructionInstrumentsPanel, written: Dict[str, str], - shown: Dict[str, bool], ) -> None: panel.update_view(NOT_LOADED) assert written == {} diff --git a/tests/unit/sampletones_core/formats/famitracker/test_footprint.py b/tests/unit/sampletones_core/formats/famitracker/test_footprint.py index 917b02a0..ca7a69fc 100644 --- a/tests/unit/sampletones_core/formats/famitracker/test_footprint.py +++ b/tests/unit/sampletones_core/formats/famitracker/test_footprint.py @@ -53,43 +53,43 @@ def build_features( class TestFeaturesFootprint(BaseTestSuite): @dataclass(frozen=True, kw_only=True) - class FootprintCase(BaseRegularTestCase): + class TestCase(BaseRegularTestCase): features: Features loop: bool expected: InstrumentFootprint test_cases = ( - FootprintCase( + TestCase( features=build_features([15, 12, 9, 0], [0, 2, 4], [1, 1, 2]), loop=False, expected=InstrumentFootprint(instrument_bytes=9, sequence_bytes=22), label="pulse_one_shot", ), - FootprintCase( + TestCase( features=build_features([15, 12, 9, 0], [0, 2, 4], [1, 1, 2]), loop=True, expected=InstrumentFootprint(instrument_bytes=9, sequence_bytes=21), label="pulse_loop", ), - FootprintCase( + TestCase( features=build_features([15, 0], [0], [0]), loop=False, expected=InstrumentFootprint(instrument_bytes=9, sequence_bytes=16), label="dimensions_of_differing_lengths", ), - FootprintCase( + TestCase( features=build_features([15, 12, 0], [0, 1], None), loop=False, expected=InstrumentFootprint(instrument_bytes=7, sequence_bytes=13), label="triangle", ), - FootprintCase( + TestCase( features=build_features([], [], None), loop=False, expected=InstrumentFootprint(instrument_bytes=3, sequence_bytes=0), label="silent", ), - FootprintCase( + TestCase( features=build_features( list(range(OVER_LONG_LENGTH)), [0] * OVER_LONG_LENGTH, @@ -99,7 +99,7 @@ class FootprintCase(BaseRegularTestCase): expected=InstrumentFootprint(instrument_bytes=7, sequence_bytes=512), label="capped_at_the_sequence_limit", ), - FootprintCase( + TestCase( features=build_features( [0] * MAX_SEQUENCE_ITEMS, [0] * MAX_SEQUENCE_ITEMS, @@ -111,23 +111,23 @@ class FootprintCase(BaseRegularTestCase): ), ) - @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label) + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) def test_both_regions_are_measured_from_the_populated_sequences( self, - case: FootprintCase, + test_case: TestCase, ) -> None: - assert features_footprint(case.features, loop=case.loop) == case.expected + assert features_footprint(test_case.features, loop=test_case.loop) == test_case.expected - @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label) - def test_the_built_instrument_measures_the_same(self, case: FootprintCase) -> None: + @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, case.label, case.features, loop=case.loop) - assert instrument_footprint(instrument) == case.expected + instrument = build_instrument(0, test_case.label, test_case.features, loop=test_case.loop) + assert instrument_footprint(instrument) == test_case.expected - @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label) - def test_the_total_sums_both_regions(self, case: FootprintCase) -> None: - footprint = features_footprint(case.features, loop=case.loop) - assert footprint.total_bytes == case.expected.instrument_bytes + case.expected.sequence_bytes + @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) + assert footprint.total_bytes == test_case.expected.instrument_bytes + test_case.expected.sequence_bytes class TestSequenceFootprint: diff --git a/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py b/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py index 13b27c77..fe7f269e 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py @@ -549,6 +549,37 @@ def test_leaves_original_untouched(self, reconstruction_factory: ReconstructionF assert reconstruction.config.nes_frequency == original_frequency assert len(reconstruction.approximation) == original_length + def test_a_channel_standing_by_stays_standing_by( + self, + reconstruction_factory: ReconstructionFactory, + ) -> None: + """A channel describing no frame renders nothing, so a retuned copy holds audio for the rest.""" + reconstruction = reconstruction_factory() + + 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,) + + 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, + [], + np.zeros(0, dtype=np.float32), + _BASE_PITCH, + (), + ) + + retuned = reconstruction.with_nes_frequency(_RETUNED_FREQUENCY) + + assert retuned.config.nes_frequency == _RETUNED_FREQUENCY + assert retuned.approximations == {} + assert retuned.approximation.size == 0 + assert retuned.playing_generators == () + def test_matching_rate_returns_self(self, reconstruction_factory: ReconstructionFactory) -> None: reconstruction = reconstruction_factory()