diff --git a/README.md b/README.md index 9f8b23777..fb38aafc1 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ _SampleToNES_ (`sampletones`) is a desktop tool for people writing music for the NES 2A03 sound chip, mainly in [_FamiTracker_](http://famitracker.com/).
- SampleToNES + SampleToNES
The core idea is to approximate an audio sample using only the chip's basic oscillators — two pulse channels, a triangle, and noise — **without any DPCM samples**. diff --git a/docs/formats/famitracker.md b/docs/formats/famitracker.md index f84d550c8..e94b9921d 100644 --- a/docs/formats/famitracker.md +++ b/docs/formats/famitracker.md @@ -411,8 +411,8 @@ reports this instead of writing a corrupt order. ## E. Driver memory footprint Compiling a module into an NSF lays each instrument out across two regions of the driver's data. An -instrument's sequences size both regions. The application shows the size before an export, so the cost of -a sample is visible in advance. +instrument's sequences size both regions. The application measures every sample and instrument in this +layout, counting each item its envelopes carry, and shows the result as the size before compression. 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 @@ -431,11 +431,12 @@ the instrument region and `Σ (4 + sᵢ)` of the sequence region. A dimension th written as a disabled slot, and only populated sequences are charged. A reconstruction that bent no note charges 3 sequences on the pulse and noise channels (volume, arpeggio, duty) and 2 on triangle. Each bend an instrument writes adds one more. Each sequence is charged at its own length (section B), so shortening -any one dimension shows in the figure. An instrument tops out at 777 bytes: three sequences at the -252-item limit. +any one dimension shows in the figure. The largest instrument FamiTracker stores takes 777 bytes: three +sequences at the 252-item limit. FamiTracker itself prints these two figures while creating an NSF, as `Instruments used: N (X bytes)` and -`Sequences used: M (Y bytes)`. A measurement can be checked against them. +`Sequences used: M (Y bytes)`. An instrument within the bounds under **Length** below can be checked +against them. **Version.** The figures are for vanilla FamiTracker 0.4.6, the target named at the top of this document. The 0CC and Dn-FamiTracker forks open each instrument body with a channel-type byte, so an instrument @@ -446,7 +447,9 @@ with the same volume envelope pay for that chunk once. A per-instrument or per-s instrument's own cost, so a module total is at most the sum of them. Within one instrument each kind appears once, so its own sequences are charged once each. -**Length.** A sequence is written at the length it holds (section B), so a figure counts each dimension as -it stands. A loop point on one dimension adds a byte and no padding. The figure for a voice is therefore -what its **Export instrument...** writes. In a module, an instrument a note slide reaches and that writes -no arpeggio takes a one-item arpeggio of its own (section B), which adds a sequence to its figure. +**Length.** The application's figure counts each dimension at the whole length its envelope carries, so a +long sample reads at its full size. A loop point on one dimension adds a byte and no padding. An export +shapes the envelopes to the file (section B): it keeps the first 252 items of a sequence, writes an +arpeggio under a bend that needs one, and gives an instrument a note slide reaches a one-item arpeggio of +its own. Within the item limit, and apart from those arpeggios, the figure is what a voice's +**Export instrument...** writes. diff --git a/docs/guide/reconstruction.md b/docs/guide/reconstruction.md index 4e346262c..fb7182a4c 100644 --- a/docs/guide/reconstruction.md +++ b/docs/guide/reconstruction.md @@ -83,7 +83,7 @@ says how many values each export keeps. Clear a sequence to play its default on every note. An empty volume sequence plays at the volume the pattern sets. An empty arpeggio, pitch or hi-pitch sequence keeps the note where the pattern puts it. An empty duty cycle sequence plays duty 0 on a pulse channel and the long mode on noise. Each channel shows -how many bytes its instrument takes on the NES, so you can see how much space an edit uses. +how many bytes its instrument takes before compression, so you can see how much space an edit uses. You can edit an [**instrument**](../glossary.md#instrument) here as well. It is a voice you write by hand, described in the [sequencer guide](sequencer.md#voices-samples-and-instruments). Right-click one diff --git a/docs/guide/sequencer.md b/docs/guide/sequencer.md index 5ed1225eb..b7aa9f0f0 100644 --- a/docs/guide/sequencer.md +++ b/docs/guide/sequencer.md @@ -43,7 +43,7 @@ say: channel, so the app asks which channel to export. The same commands are on the **Edit** menu for the voice you selected. The right-click menu also -shows how many bytes the voice takes on the NES. This matters when you export an NSF program. +shows how many bytes the voice takes before compression. An NSF export compresses it. Removing a voice that patterns still use asks you first, and clears every row that uses it. diff --git a/docs/images/sampletones.png b/docs/images/sampletones.png index 3a95d82e5..a8161e34c 100644 Binary files a/docs/images/sampletones.png and b/docs/images/sampletones.png differ diff --git a/src/sampletones_application/logic/reconstruction/instruments.py b/src/sampletones_application/logic/reconstruction/instruments.py index 5b374eb84..84c282f55 100644 --- a/src/sampletones_application/logic/reconstruction/instruments.py +++ b/src/sampletones_application/logic/reconstruction/instruments.py @@ -227,11 +227,11 @@ def _build_footprint( self, channels: Dict[ChannelName, Features], ) -> VoiceFootprintViewModel: - """Measures each playing channel's instrument as the size its own export writes. + """Measures the raw size of each playing channel's instrument. - Each instrument is measured at the lengths its own envelopes state, matching what - **Export instrument...** produces. A channel standing by is written nowhere, so it is - measured nowhere and the sample's total names what the export costs. + Each instrument is measured at the whole lengths its own envelopes state. A channel standing + by is written nowhere, so it is measured nowhere and the sample's total names the channels + that play. """ return VoiceFootprintViewModel.from_footprints( { diff --git a/src/sampletones_application/logic/sequencer/voices.py b/src/sampletones_application/logic/sequencer/voices.py index 6bf2a9eba..bb3e15dd0 100644 --- a/src/sampletones_application/logic/sequencer/voices.py +++ b/src/sampletones_application/logic/sequencer/voices.py @@ -190,7 +190,7 @@ def build_voice_footprint( self, voice_id: str, ) -> Optional[VoiceFootprintViewModel]: - """Measures one voice's instruments as the module export writes them. + """Measures the raw size of one voice's instruments, every item their envelopes carry. A sample yields a figure per channel its reconstruction covers; an instrument yields one, since every channel reaches the same envelopes. Measuring a single voice on demand keeps a diff --git a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py index b75d866c0..6ae4185db 100644 --- a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py +++ b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py @@ -268,10 +268,10 @@ def _create_size_field( ) -> 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 color and font. A tooltip - names the export the figure measures, since the formats spend differently. + The figure names the raw size of the sound's data, 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 color and font. A tooltip says the figure is measured + before compression, which an NSF export applies. """ with labeled_field( label, diff --git a/src/sampletones_application/view_model/shared/footprint.py b/src/sampletones_application/view_model/shared/footprint.py index afe0f6364..6a07087d8 100644 --- a/src/sampletones_application/view_model/shared/footprint.py +++ b/src/sampletones_application/view_model/shared/footprint.py @@ -10,7 +10,7 @@ class InstrumentSizeViewModel(BaseModel, frozen=True): - """The bytes one instrument occupies once a tracker compiles it. + """The raw bytes one instrument's envelopes take, every item counted. The measurement is carried as it was taken, both regions intact, so a display naming the whole and one naming a region read the same figure. An instrument naming a channel is a diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 430944e85..0c8ae56d0 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -190,8 +190,8 @@ global.context.label.detail_reconstructions: "Reconstructions" global.context.label.detail_stems: "Stems" 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.context.template.size_bytes: "{bytes} B (uncompressed)" +global.context.tooltip.size_bytes: "The size of this sound's data before NSF compression." # ============================================================================= # Global — Menu @@ -690,8 +690,8 @@ sequencer.voices.title.add_sample_dialog: "Add sample" sequencer.voices.title.import_instrument_dialog: "Import instrument" sequencer.voices.title.instrument_imported: "Instrument imported" sequencer.voices.template.instrument_name: "Instrument {position}" -sequencer.voices.template.status_sample: "{name} is a sample playing {channels}. It takes {bytes} as FamiTracker instruments." -sequencer.voices.template.status_instrument: "{name} is an instrument every channel can play. It takes {bytes} as a FamiTracker instrument." +sequencer.voices.template.status_sample: "{name} is a sample playing {channels}. It takes {bytes}." +sequencer.voices.template.status_instrument: "{name} is an instrument every channel can play. It takes {bytes}." sequencer.voices.template.status_channel_separator: ", " sequencer.voices.template.instrument_omissions: "\"{name}\" plays every envelope the file states.\nThe file also states:" diff --git a/src/sampletones_core/formats/famitracker/footprint.py b/src/sampletones_core/formats/famitracker/footprint.py index dc23cdd09..d29c60058 100644 --- a/src/sampletones_core/formats/famitracker/footprint.py +++ b/src/sampletones_core/formats/famitracker/footprint.py @@ -3,11 +3,7 @@ from sampletones_core.constants.enums import ChannelName from sampletones_core.exporters.feature import Features -from sampletones_core.formats.famitracker.model.instrument import Instrument2A03 -from sampletones_core.formats.famitracker.model.sequence import InstrumentSequence -from sampletones_core.formats.famitracker.sequences.features import ( - features_to_instrument_sequences, -) +from sampletones_core.features.envelope import Envelope from sampletones_core.formats.famitracker.specification.memory import ( INSTRUMENT_DEFINITION_BYTES, SEQUENCE_HEADER_BYTES, @@ -19,12 +15,13 @@ @dataclass(frozen=True) class InstrumentFootprint: - """The bytes an instrument occupies once FamiTracker compiles it into an NSF. + """The raw bytes an instrument's envelopes take, laid out the way FamiTracker's driver lays them out. 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. + the sequence chunks the body points at under ``sequence_bytes``. Every item an envelope + carries is counted, so the figure is the size of the sound's data before any compression. + See `docs/formats/famitracker.md` for the layout each figure counts. Attributes: instrument_bytes: Bytes the instrument's table entry and body occupy. @@ -40,38 +37,18 @@ def total_bytes(self) -> int: 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 envelope_footprint(envelope: Envelope[int]) -> int: + """Measures the bytes one envelope occupies as a sequence chunk: its four-field header and its items.""" + return SEQUENCE_HEADER_BYTES + SEQUENCE_ITEM_BYTES * len(envelope.items) def features_footprint(features: Features) -> InstrumentFootprint: - """Measures the instrument a channel slice's envelopes export to. + """Measures the instrument a channel slice's envelopes make up, every item they carry counted. - The envelopes pass through the same builder an export uses, so the measured item counts are - the ones a file carries: each at the length it was written, capped at what a FamiTracker - sequence holds. + A written envelope earns the instrument a pointer to its chunk and contributes the chunk at + its whole length; a dimension left to the channel is a disabled slot the driver stores + nothing for. The figure describes the envelopes as the application plays them, and an export + to FamiTracker shapes them to its own sequences (see `docs/formats/famitracker.md`). Args: features: The per-dimension envelopes describing the slice. @@ -79,8 +56,11 @@ def features_footprint(features: Features) -> InstrumentFootprint: Returns: InstrumentFootprint: The footprint of the instrument those envelopes describe. """ - sequences = features_to_instrument_sequences(features, repitched=False) - return sequences_footprint(sequences.values()) + written = [envelope for envelope in features.envelopes.values() if envelope.written] + return InstrumentFootprint( + instrument_bytes=INSTRUMENT_DEFINITION_BYTES + SEQUENCE_POINTER_BYTES * len(written), + sequence_bytes=sum(envelope_footprint(envelope) for envelope in written), + ) def reconstruction_footprints(reconstruction: Reconstruction) -> Dict[ChannelName, InstrumentFootprint]: 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 cc7135b7b..b10ceebf0 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 @@ -62,6 +62,7 @@ from tests.suite.case import BaseRegularTestCase SEQUENCE_STATUS_KEY: Final[str] = "reconstructions.instruments.message.status_sequence" +SIZE_TEMPLATE_KEY: Final[str] = "global.context.template.size_bytes" KEPT_KEYS: Final[Dict[ExportFormat, str]] = { ExportFormat.FAMITRACKER: "reconstructions.instruments.template.kept_famitracker", ExportFormat.BITPHASE: "reconstructions.instruments.template.kept_bitphase", @@ -549,19 +550,24 @@ def test_the_status_names_each_export_that_shortens_the_dimension( assert str(test_case.item_count) in message +def size_text(panel: GUIReconstructionInstrumentsPanel, byte_count: int) -> str: + """A byte figure as the shipped template prints it.""" + return panel._language_manager[SIZE_TEMPLATE_KEY].format(bytes=byte_count) + + 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 TestCase(BaseRegularTestCase): channel_footprints: Dict[ChannelName, InstrumentFootprint] - expected: str + expected_bytes: int test_cases = ( TestCase( label="a single channel spends what its instrument does", channel_footprints={ChannelName.PULSE1: LARGEST_PULSE}, - expected="777 B", + expected_bytes=777, ), TestCase( label="three channels spend their instruments together", @@ -570,12 +576,12 @@ class TestCase(BaseRegularTestCase): ChannelName.TRIANGLE: LARGEST_TRIANGLE, ChannelName.NOISE: LARGEST_PULSE, }, - expected="2073 B", + expected_bytes=2073, ), TestCase( label="a silent channel spends the instrument definition alone", channel_footprints={ChannelName.TRIANGLE: SILENT_INSTRUMENT}, - expected="3 B", + expected_bytes=3, ), ) @@ -587,7 +593,7 @@ def test_the_sample_size_sums_its_channels( test_case: TestCase, ) -> None: panel.update_view(build_view_model(test_case.channel_footprints)) - assert written[panel.sample_size_tag] == test_case.expected + assert written[panel.sample_size_tag] == size_text(panel, test_case.expected_bytes) @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) def test_each_channel_states_its_own_size( @@ -601,7 +607,7 @@ def test_each_channel_states_its_own_size( channel_name: written[panel._get_instrument_size_tag(channel_name)] for channel_name in test_case.channel_footprints } == { - channel_name: f"{footprint.total_bytes} B" + channel_name: size_text(panel, footprint.total_bytes) for channel_name, footprint in test_case.channel_footprints.items() } @@ -619,7 +625,7 @@ def test_a_channel_standing_by_costs_nothing( for channel_name in ChannelName.items() if channel_name not in test_case.channel_footprints } == { - channel_name: "0 B" + channel_name: size_text(panel, 0) for channel_name in ChannelName.items() if channel_name not in test_case.channel_footprints } diff --git a/tests/unit/sampletones_core/formats/famitracker/test_footprint.py b/tests/unit/sampletones_core/formats/famitracker/test_footprint.py index 5cd3c8841..7039620b9 100644 --- a/tests/unit/sampletones_core/formats/famitracker/test_footprint.py +++ b/tests/unit/sampletones_core/formats/famitracker/test_footprint.py @@ -1,32 +1,24 @@ from dataclasses import dataclass from typing import Final, Optional, Sequence -import numpy as np import pytest from sampletones_core.constants.enums import ChannelName from sampletones_core.exporters.feature import Features from sampletones_core.features.envelope import Envelope -from sampletones_core.formats.famitracker.builder import build_instrument from sampletones_core.formats.famitracker.footprint import ( InstrumentFootprint, + envelope_footprint, 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 sampletones_core.formats.famitracker.specification.sequences import MAX_SEQUENCE_ITEMS from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase @@ -96,8 +88,11 @@ class TestCase(BaseRegularTestCase): [0] * OVER_LONG_LENGTH, None, ), - expected=InstrumentFootprint(instrument_bytes=7, sequence_bytes=512), - label="capped_at_the_sequence_limit", + expected=InstrumentFootprint( + instrument_bytes=7, + sequence_bytes=2 * (SEQUENCE_HEADER_BYTES + OVER_LONG_LENGTH), + ), + label="past_the_sequence_limit", ), TestCase( features=build_features( @@ -106,7 +101,7 @@ class TestCase(BaseRegularTestCase): [0] * MAX_SEQUENCE_ITEMS, ), expected=InstrumentFootprint(instrument_bytes=9, sequence_bytes=768), - label="largest_instrument_famitracker_holds", + label="at_the_sequence_limit", ), ) @@ -117,29 +112,20 @@ def test_both_regions_are_measured_from_the_populated_sequences( ) -> None: assert features_footprint(test_case.features) == test_case.expected - @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) - def test_the_built_instrument_measures_the_same(self, test_case: TestCase) -> None: - """Both entry points measure one export, so a slice reads the same either way.""" - instrument = build_instrument(0, test_case.label, test_case.features, repitched=False) - assert instrument_footprint(instrument) == test_case.expected - @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) def test_the_total_sums_both_regions(self, test_case: TestCase) -> None: footprint = features_footprint(test_case.features) assert footprint.total_bytes == test_case.expected.instrument_bytes + test_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 +class TestEnvelopeFootprint: + def test_an_envelope_holds_its_header_and_one_byte_per_item(self) -> None: + envelope = Envelope[int](items=(15, 12, 9)) + assert envelope_footprint(envelope) == 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) + def test_an_empty_dimension_costs_nothing(self) -> None: + features = build_features([15, 12], [], None) + footprint = features_footprint(features) assert footprint.instrument_bytes == INSTRUMENT_DEFINITION_BYTES + SEQUENCE_POINTER_BYTES assert footprint.sequence_bytes == SEQUENCE_HEADER_BYTES + 2