Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/).

<div align="center">
<img src="https://raw.githubusercontent.com/JakimPL/SampleToNES/main/docs/images/sampletones.png" alt="SampleToNES" width="640">
<img src="https://raw.githubusercontent.com/JakimPL/SampleToNES/main/docs/images/sampletones.png" alt="SampleToNES" width="760">
</div>

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**.
Expand Down
21 changes: 12 additions & 9 deletions docs/formats/famitracker.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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.
2 changes: 1 addition & 1 deletion docs/guide/reconstruction.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/guide/sequencer.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Binary file modified docs/images/sampletones.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -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(
{
Expand Down
2 changes: 1 addition & 1 deletion src/sampletones_application/logic/sequencer/voices.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/sampletones_application/view_model/shared/footprint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions src/sampletones_config/lang/en.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:"

Expand Down
56 changes: 18 additions & 38 deletions src/sampletones_core/formats/famitracker/footprint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.
Expand All @@ -40,47 +37,30 @@ 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.

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]:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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,
),
)

Expand All @@ -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(
Expand All @@ -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()
}

Expand All @@ -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
}
Expand Down
Loading
Loading