From e995840291e1f7e35ee37273667e35363d7695c6 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 20 Sep 2026 22:24:05 +0200 Subject: [PATCH 01/11] Added: the packed-plane layout the compression study prices --- .../codec/study/packing/__init__.py | 0 .../codec/study/packing/form.py | 152 ++++++++++++++++ .../codec/study/packing/registers.py | 60 ++++++ .../codec/study/packing/symbols.py | 104 +++++++++++ .../codec/study/test_packing.py | 172 ++++++++++++++++++ 5 files changed, 488 insertions(+) create mode 100644 src/sampletones_tools/codec/study/packing/__init__.py create mode 100644 src/sampletones_tools/codec/study/packing/form.py create mode 100644 src/sampletones_tools/codec/study/packing/registers.py create mode 100644 src/sampletones_tools/codec/study/packing/symbols.py create mode 100644 tests/unit/sampletones_tools/codec/study/test_packing.py diff --git a/src/sampletones_tools/codec/study/packing/__init__.py b/src/sampletones_tools/codec/study/packing/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_tools/codec/study/packing/form.py b/src/sampletones_tools/codec/study/packing/form.py new file mode 100644 index 000000000..1bf1f94af --- /dev/null +++ b/src/sampletones_tools/codec/study/packing/form.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +from typing import Final + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from sampletones_player.specification.binary import MAX_BYTE_VALUE + +SINGLE_TICK: Final[int] = 1 +NO_BITS: Final[int] = 0 + + +class PlaneForm(BaseModel): + """How a plane's byte divides between the value its register reads and a repeat count. + + A channel's register reads some of a byte's bits and ignores the rest: a pulse channel's + volume is four bits under two the hardware wants set, and a noise period is four bits with + three above it that say nothing. A plane writes its value in the bits that reach the register + and counts the ticks that value repeats for in the ones left over, so a run costs one byte + however long it rests. + + The division is a mask, which is what keeps the reading cheap: the register byte is the + symbol masked and ored with the bits the hardware fixes, and one repeat is a subtraction of + ``count_step``. A plane whose values need every bit states a full ``value_mask``, carries no + count, and reads byte for byte as it does today. + + Attributes: + value_mask: The bits the value occupies. + value_or: The bits the register fixes, which every value of the plane carries. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + value_mask: int = Field(..., ge=0, le=MAX_BYTE_VALUE) + value_or: int = Field(..., ge=0, le=MAX_BYTE_VALUE) + + @model_validator(mode="after") + def _validate_the_fixed_bits_lie_outside_the_value(self) -> PlaneForm: + if self.value_or & self.value_mask: + raise ValueError( + f"the bits a register fixes lie outside the value's own, and {self.value_or:#04x} " + f"meets {self.value_mask:#04x}" + ) + + return self + + @model_validator(mode="after") + def _validate_the_count_occupies_one_run_of_bits(self) -> PlaneForm: + if not self.count_mask: + return self + + counted = self.count_mask // self.count_step + if counted & (counted + 1): + raise ValueError( + f"a repeat count occupies one run of bits, and the value breaks {self.count_mask:#04x} apart" + ) + + return self + + @property + def count_mask(self) -> int: + """The bits a repeat count occupies.""" + return MAX_BYTE_VALUE ^ self.value_mask + + @property + def count_step(self) -> int: + """What one further repeat adds to a symbol.""" + return self.count_mask & -self.count_mask + + @property + def repeats(self) -> int: + """The ticks one symbol covers at most.""" + if not self.count_mask: + return SINGLE_TICK + + return self.count_mask // self.count_step + 1 + + @property + def counts(self) -> bool: + """Whether the plane's byte has room to count a repeat.""" + return self.repeats > SINGLE_TICK + + def symbol(self, value: int, repeats: int) -> int: + """The byte a plane writes for ``value`` sounding ``repeats`` ticks running. + + Args: + value: The register byte the ticks play. + repeats: The ticks the value sounds for, up to ``repeats``. + + Returns: + int: The symbol. + + Raises: + ValueError: If the value carries fixed bits other than the form's, or the repeats + reach past what one symbol counts. + """ + if value & self.count_mask != self.value_or: + raise ValueError( + f"a value of this plane carries {self.value_or:#04x} in the bits the count takes, " + f"and {value:#04x} carries {value & self.count_mask:#04x}" + ) + + if not SINGLE_TICK <= repeats <= self.repeats: + raise ValueError(f"one symbol counts {SINGLE_TICK} through {self.repeats} ticks, and this counts {repeats}") + + return (value & self.value_mask) | (repeats - SINGLE_TICK) * self.count_step + + def value(self, symbol: int) -> int: + """The register byte ``symbol`` plays.""" + return (symbol & self.value_mask) | self.value_or + + def repeated(self, symbol: int) -> int: + """The ticks ``symbol`` covers.""" + if not self.count_mask: + return SINGLE_TICK + + return (symbol & self.count_mask) // self.count_step + SINGLE_TICK + + @property + def seeded(self) -> int: + """The register byte a plane stands at before its first token. + + The driver seeds every plane to the value zero states, which under a form is the bits the + register fixes: a pulse channel's control byte seeds to a sustained silence rather than to + nothing at all. A plane holding that value throughout therefore takes no stream. + """ + return self.value(NO_BITS) + + def idles(self, plane: bytes) -> bool: + """Whether the plane holds the value it is seeded to throughout, so it takes no stream. + + Args: + plane: The values the plane plays. + + Returns: + bool: Whether the plane leaves its seeded value. + """ + return set(plane) <= {self.seeded} + + def fits(self, plane: bytes) -> bool: + """Whether every value the plane plays carries the bits this form fixes. + + Args: + plane: The values the plane plays. + + Returns: + bool: Whether the form reads the plane. + """ + return all(value & self.count_mask == self.value_or for value in plane) + + +WHOLE_BYTE: Final[PlaneForm] = PlaneForm(value_mask=MAX_BYTE_VALUE, value_or=NO_BITS) diff --git a/src/sampletones_tools/codec/study/packing/registers.py b/src/sampletones_tools/codec/study/packing/registers.py new file mode 100644 index 000000000..abd909405 --- /dev/null +++ b/src/sampletones_tools/codec/study/packing/registers.py @@ -0,0 +1,60 @@ +from typing import Dict, Final, Tuple + +from sampletones_core.constants.general import MAX_DUTY_CYCLE, MAX_PERIOD, MAX_VOLUME +from sampletones_player.compression.planes.order import PlaneOrder +from sampletones_player.specification.registers import ( + DUTY_CYCLE_SHIFT, + NOISE_MODE_SHIFT, + SUSTAINED_LEVEL, +) +from sampletones_tools.codec.study.packing.form import NO_BITS, WHOLE_BYTE, PlaneForm + +DUTY_CYCLE_FIELD: Final[int] = MAX_DUTY_CYCLE << DUTY_CYCLE_SHIFT +VOLUME_FIELD: Final[int] = MAX_VOLUME +PERIOD_FIELD: Final[int] = MAX_PERIOD +NOISE_MODE_FIELD: Final[int] = 1 << NOISE_MODE_SHIFT + +PULSE_CONTROL_FORM: Final[PlaneForm] = PlaneForm( + value_mask=DUTY_CYCLE_FIELD | VOLUME_FIELD, + value_or=SUSTAINED_LEVEL, +) +NOISE_CONTROL_FORM: Final[PlaneForm] = PlaneForm( + value_mask=VOLUME_FIELD, + value_or=SUSTAINED_LEVEL, +) +NOISE_VALUE_FORM: Final[PlaneForm] = PlaneForm( + value_mask=NOISE_MODE_FIELD | PERIOD_FIELD, + value_or=NO_BITS, +) + +PLANE_FORMS: Final[Dict[str, PlaneForm]] = { + "pulse1_control": PULSE_CONTROL_FORM, + "pulse1_value": WHOLE_BYTE, + "pulse1_bend": WHOLE_BYTE, + "pulse2_control": PULSE_CONTROL_FORM, + "pulse2_value": WHOLE_BYTE, + "pulse2_bend": WHOLE_BYTE, + "triangle_control": WHOLE_BYTE, + "triangle_value": WHOLE_BYTE, + "triangle_bend": WHOLE_BYTE, + "noise_control": NOISE_CONTROL_FORM, + "noise_value": NOISE_VALUE_FORM, +} + + +def register_forms() -> Tuple[PlaneForm, ...]: + """The form each plane's own register fixes, in the order the song block writes the planes. + + A pulse channel's control byte holds its duty cycle and volume around two bits the hardware + wants set, a noise channel's control byte holds a volume under the same two, and a noise + period byte holds a mode bit above three the register reads nothing from. Those are the bits + a repeat count rides in without the block stating anything. Every other plane spends its + whole byte, so it reads as it does today. + + Returns: + Tuple[PlaneForm, ...]: One form per plane, in song-block order. + + Raises: + KeyError: If a plane the song block writes names no form. + """ + return tuple(PLANE_FORMS[name] for name in PlaneOrder.names()) diff --git a/src/sampletones_tools/codec/study/packing/symbols.py b/src/sampletones_tools/codec/study/packing/symbols.py new file mode 100644 index 000000000..a6d9f2cf6 --- /dev/null +++ b/src/sampletones_tools/codec/study/packing/symbols.py @@ -0,0 +1,104 @@ +from typing import FrozenSet, List, Tuple + +from sampletones_tools.codec.study.packing.form import SINGLE_TICK, PlaneForm + + +def pack_plane( + plane: bytes, + form: PlaneForm, + *, + boundaries: FrozenSet[int], +) -> bytes: + """The symbols a plane plays, each a value and the ticks it repeats for. + + A run of one value becomes one symbol for as many ticks as the form's count reaches, and a + longer run becomes as many symbols as that takes. A tick the song re-enters on begins a + symbol of its own, so the plane stands at a symbol's first byte wherever a token may start. + + Args: + plane: The values the plane plays. + form: How the plane's byte divides. + boundaries: The ticks a symbol begins on, beyond the plane's first. + + Returns: + bytes: The symbols, in the order they are played. + + Raises: + ValueError: If a value the plane plays carries fixed bits other than the form's. + """ + symbols = bytearray() + for start, length in _runs(plane, boundaries=boundaries): + held = length + while held: + repeats = min(held, form.repeats) + symbols.append(form.symbol(plane[start], repeats)) + held -= repeats + + return bytes(symbols) + + +def unpack_plane( + symbols: bytes, + form: PlaneForm, +) -> bytes: + """The values a run of symbols plays, one per tick. + + Args: + symbols: The symbols the plane is written as. + form: How the plane's byte divides. + + Returns: + bytes: The values, one per tick. + """ + values = bytearray() + for symbol in symbols: + values.extend((form.value(symbol),) * form.repeated(symbol)) + + return bytes(values) + + +def symbol_boundaries( + plane: bytes, + form: PlaneForm, + *, + boundaries: FrozenSet[int], +) -> FrozenSet[int]: + """Where the ticks a token starts on stand once the plane is packed. + + A packed plane is read a symbol at a time, so the parse counts symbols where it counted + ticks and a boundary reaches it as the symbol the tick begins. + + Args: + plane: The values the plane plays. + form: How the plane's byte divides. + boundaries: The ticks a token starts on, beyond the plane's first. + + Returns: + FrozenSet[int]: The symbols those ticks begin. + """ + positions = {} + symbol = 0 + for start, length in _runs(plane, boundaries=boundaries): + positions[start] = symbol + symbol += -(-length // form.repeats) + + return frozenset(positions[tick] for tick in boundaries if tick in positions) + + +def _runs( + plane: bytes, + *, + boundaries: FrozenSet[int], +) -> Tuple[Tuple[int, int], ...]: + """Where each run of one value begins and how many ticks it lasts, cut at every boundary.""" + runs: List[Tuple[int, int]] = [] + start = 0 + for tick in range(SINGLE_TICK, len(plane)): + if plane[tick] != plane[start] or tick in boundaries: + runs.append((start, tick - start)) + start = tick + + if plane: + runs.append((start, len(plane) - start)) + + return tuple(runs) diff --git a/tests/unit/sampletones_tools/codec/study/test_packing.py b/tests/unit/sampletones_tools/codec/study/test_packing.py new file mode 100644 index 000000000..da9d1d3a8 --- /dev/null +++ b/tests/unit/sampletones_tools/codec/study/test_packing.py @@ -0,0 +1,172 @@ +from math import ceil +from typing import Final, FrozenSet, Tuple + +import pytest + +from sampletones_core.constants.general import MAX_VOLUME +from sampletones_player.compression.planes.order import PlaneOrder +from sampletones_player.specification.binary import MAX_BYTE_VALUE +from sampletones_player.specification.registers import SUSTAINED_LEVEL +from sampletones_tools.codec.study.packing.form import NO_BITS, WHOLE_BYTE, PlaneForm +from sampletones_tools.codec.study.packing.registers import ( + NOISE_CONTROL_FORM, + NOISE_VALUE_FORM, + PULSE_CONTROL_FORM, + register_forms, +) +from sampletones_tools.codec.study.packing.symbols import ( + pack_plane, + symbol_boundaries, + unpack_plane, +) + +NO_BOUNDARIES: Final[FrozenSet[int]] = frozenset() +QUIET: Final[int] = SUSTAINED_LEVEL +LOUD: Final[int] = SUSTAINED_LEVEL | MAX_VOLUME +LONG_REST: Final[int] = 200 + + +def _noise_control(*volumes: int) -> bytes: + return bytes(SUSTAINED_LEVEL | volume for volume in volumes) + + +class TestHowAPlanesByteDivides: + def test_a_pulse_control_byte_counts_in_the_bits_the_hardware_fixes(self) -> None: + assert PULSE_CONTROL_FORM.count_mask == SUSTAINED_LEVEL + assert PULSE_CONTROL_FORM.repeats == 4 + + def test_a_noise_control_byte_counts_in_its_whole_top_nibble(self) -> None: + assert NOISE_CONTROL_FORM.repeats == MAX_VOLUME + 1 + + def test_a_noise_period_byte_counts_under_its_mode_bit(self) -> None: + assert NOISE_VALUE_FORM.repeats == 8 + + def test_a_plane_spending_its_whole_byte_counts_nothing(self) -> None: + assert not WHOLE_BYTE.counts + assert WHOLE_BYTE.repeats == 1 + + def test_the_song_block_names_a_form_for_every_plane(self) -> None: + assert len(register_forms()) == len(PlaneOrder.names()) + + def test_the_value_a_symbol_plays_carries_the_bits_the_register_fixes(self) -> None: + symbol = NOISE_CONTROL_FORM.symbol(LOUD, repeats=NOISE_CONTROL_FORM.repeats) + + assert NOISE_CONTROL_FORM.value(symbol) == LOUD + assert NOISE_CONTROL_FORM.repeated(symbol) == NOISE_CONTROL_FORM.repeats + + def test_a_control_plane_is_seeded_to_a_sustained_silence(self) -> None: + assert PULSE_CONTROL_FORM.seeded == SUSTAINED_LEVEL + assert NOISE_CONTROL_FORM.seeded == SUSTAINED_LEVEL + + def test_a_plane_spending_its_whole_byte_is_seeded_to_nothing(self) -> None: + assert WHOLE_BYTE.seeded == NO_BITS + + def test_a_channel_standing_by_leaves_its_control_plane_idle(self) -> None: + assert PULSE_CONTROL_FORM.idles(_noise_control(*([0] * 8))) + + def test_a_channel_that_sounds_leaves_its_control_plane_standing(self) -> None: + assert not PULSE_CONTROL_FORM.idles(_noise_control(0, 0, MAX_VOLUME, 0)) + + def test_a_form_whose_count_bits_meet_its_value_is_refused(self) -> None: + with pytest.raises(ValueError): + PlaneForm(value_mask=0x0F, value_or=0x01) + + def test_a_form_whose_count_bits_lie_apart_is_refused(self) -> None: + with pytest.raises(ValueError): + PlaneForm(value_mask=0x7E, value_or=NO_BITS) + + def test_a_value_carrying_other_fixed_bits_is_refused(self) -> None: + with pytest.raises(ValueError): + NOISE_CONTROL_FORM.symbol(MAX_VOLUME, repeats=1) + + def test_a_count_past_what_one_symbol_holds_is_refused(self) -> None: + with pytest.raises(ValueError): + PULSE_CONTROL_FORM.symbol(QUIET, repeats=PULSE_CONTROL_FORM.repeats + 1) + + +class TestWhatAPackedPlanePlays: + def test_the_ticks_come_back_as_they_were_written(self) -> None: + plane = _noise_control(0, 0, 0, 5, 5, 1, 1, 1, 1, 1, 1, 0) + + packed = pack_plane(plane, NOISE_CONTROL_FORM, boundaries=NO_BOUNDARIES) + + assert unpack_plane(packed, NOISE_CONTROL_FORM) == plane + + def test_a_run_of_one_value_costs_one_symbol(self) -> None: + plane = _noise_control(*([MAX_VOLUME] * NOISE_CONTROL_FORM.repeats)) + + assert len(pack_plane(plane, NOISE_CONTROL_FORM, boundaries=NO_BOUNDARIES)) == 1 + + def test_a_run_past_what_one_symbol_counts_takes_as_many_as_it_needs(self) -> None: + ticks = NOISE_CONTROL_FORM.repeats * 3 + 1 + plane = _noise_control(*([MAX_VOLUME] * ticks)) + + packed = pack_plane(plane, NOISE_CONTROL_FORM, boundaries=NO_BOUNDARIES) + + assert len(packed) == 4 + assert unpack_plane(packed, NOISE_CONTROL_FORM) == plane + + def test_a_long_rest_falls_to_a_fraction_of_its_ticks(self) -> None: + plane = _noise_control(*([0] * LONG_REST)) + + packed = pack_plane(plane, NOISE_CONTROL_FORM, boundaries=NO_BOUNDARIES) + + assert len(packed) == ceil(LONG_REST / NOISE_CONTROL_FORM.repeats) + + def test_a_plane_spending_its_whole_byte_packs_to_itself(self) -> None: + plane = bytes(range(64)) * 2 + + assert pack_plane(plane, WHOLE_BYTE, boundaries=NO_BOUNDARIES) == plane + + def test_every_byte_a_symbol_may_take_plays_back(self) -> None: + symbols = bytes(range(MAX_BYTE_VALUE + 1)) + + played = unpack_plane(symbols, NOISE_VALUE_FORM) + + assert len(played) == sum(NOISE_VALUE_FORM.repeated(symbol) for symbol in symbols) + assert set(played) <= {NOISE_VALUE_FORM.value(symbol) for symbol in symbols} + + def test_a_plane_of_no_ticks_packs_to_no_symbols(self) -> None: + assert pack_plane(b"", NOISE_CONTROL_FORM, boundaries=NO_BOUNDARIES) == b"" + + +class TestWhereATokenStartsOnAPackedPlane: + def test_a_boundary_begins_a_symbol_of_its_own(self) -> None: + plane = _noise_control(*([MAX_VOLUME] * 8)) + boundaries: FrozenSet[int] = frozenset({3}) + + packed = pack_plane(plane, NOISE_CONTROL_FORM, boundaries=boundaries) + + assert unpack_plane(packed, NOISE_CONTROL_FORM) == plane + assert NOISE_CONTROL_FORM.repeated(packed[0]) == 3 + + def test_the_boundary_reaches_the_symbol_the_tick_begins(self) -> None: + plane = _noise_control(*([MAX_VOLUME] * 8)) + boundaries: FrozenSet[int] = frozenset({3}) + + assert symbol_boundaries(plane, NOISE_CONTROL_FORM, boundaries=boundaries) == frozenset({1}) + + def test_a_plane_spending_its_whole_byte_keeps_its_boundaries(self) -> None: + plane = bytes(range(16)) + boundaries: FrozenSet[int] = frozenset({4, 9}) + + assert symbol_boundaries(plane, WHOLE_BYTE, boundaries=boundaries) == boundaries + + def test_several_boundaries_each_begin_a_symbol(self) -> None: + plane = _noise_control(*([0] * 40)) + boundaries: FrozenSet[int] = frozenset({5, 17, 33}) + + packed = pack_plane(plane, NOISE_CONTROL_FORM, boundaries=boundaries) + starts = symbol_boundaries(plane, NOISE_CONTROL_FORM, boundaries=boundaries) + + assert unpack_plane(packed, NOISE_CONTROL_FORM) == plane + assert len(starts) == len(boundaries) + assert _ticks_before(packed, starts) == {5, 17, 33} + + +def _ticks_before(packed: bytes, starts: FrozenSet[int]) -> FrozenSet[int]: + """The tick each named symbol begins on, read by playing the symbols before it.""" + reached: Tuple[int, ...] = tuple( + sum(NOISE_CONTROL_FORM.repeated(symbol) for symbol in packed[:start]) for start in sorted(starts) + ) + return frozenset(reached) From 2968eb645bad131b7cb139a3e6387f1d1d934e8d Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 20 Sep 2026 22:43:39 +0200 Subject: [PATCH 02/11] Added: the repeated-value grammars the study compares --- .../codec/study/layouts/encode.py | 2 + src/sampletones_tools/codec/study/measure.py | 5 +- .../codec/study/packing/coding.py | 35 +++++ .../codec/study/packing/encode.py | 115 ++++++++++++++ .../codec/study/packing/fitted.py | 74 +++++++++ .../codec/study/packing/grammar.py | 101 +++++++++++++ .../codec/study/packing/scheme.py | 75 +++++++++ .../codec/study/packing/symbols.py | 9 +- .../codec/study/packing/table.py | 142 ++++++++++++++++++ .../codec/study/sandbox/encode.py | 4 +- .../codec/study/sandbox/reference.py | 16 +- .../codec/study/sandbox/verify.py | 7 +- .../codec/study/variants/packing.py | 96 ++++++++++++ .../codec/study/variants/registry.py | 3 + tests/integration/tooling/test_codec_study.py | 24 +++ .../codec/study/test_packing.py | 128 ++++++++++++++++ .../codec/study/test_verdicts.py | 3 + 17 files changed, 823 insertions(+), 16 deletions(-) create mode 100644 src/sampletones_tools/codec/study/packing/coding.py create mode 100644 src/sampletones_tools/codec/study/packing/encode.py create mode 100644 src/sampletones_tools/codec/study/packing/fitted.py create mode 100644 src/sampletones_tools/codec/study/packing/grammar.py create mode 100644 src/sampletones_tools/codec/study/packing/scheme.py create mode 100644 src/sampletones_tools/codec/study/packing/table.py create mode 100644 src/sampletones_tools/codec/study/variants/packing.py diff --git a/src/sampletones_tools/codec/study/layouts/encode.py b/src/sampletones_tools/codec/study/layouts/encode.py index e300b664b..f0e121a8a 100644 --- a/src/sampletones_tools/codec/study/layouts/encode.py +++ b/src/sampletones_tools/codec/study/layouts/encode.py @@ -3,6 +3,7 @@ from sampletones_player.compression.decode import decode_plane from sampletones_player.compression.encode import encode_streams from sampletones_player.compression.options import EVERY_LAYER +from sampletones_player.specification.song import SONG_HEADER_SIZE from sampletones_tools.codec.study.corpus.song import StudySong from sampletones_tools.codec.study.layouts.layout import PlaneLayout from sampletones_tools.codec.study.layouts.planes import layout_planes, layout_seeds @@ -36,6 +37,7 @@ def encode_layout( seconds = process_time() - started return Encoding( + header=SONG_HEADER_SIZE, phrases=len(table), dictionary=table.size, streams=tuple(len(stream) for stream in streams), diff --git a/src/sampletones_tools/codec/study/measure.py b/src/sampletones_tools/codec/study/measure.py index e116fcec4..22c674733 100644 --- a/src/sampletones_tools/codec/study/measure.py +++ b/src/sampletones_tools/codec/study/measure.py @@ -16,6 +16,7 @@ class Encoding: stated as the bytes each would take, so the two kinds sit in one report on the same terms. Attributes: + header: The bytes the song block's header takes, which a format stating more of itself grows. phrases: The phrases the dictionary holds. dictionary: The bytes the dictionary takes. streams: The bytes each plane's stream takes, in the order the song block writes them. @@ -24,6 +25,7 @@ class Encoding: written: The streams as the driver reads them, where the variant writes its grammar. """ + header: int phrases: int dictionary: int streams: Tuple[int, ...] @@ -52,7 +54,7 @@ class Measurement: @property def block(self) -> int: """The bytes the whole song block takes: header, pitch table, dictionary and streams.""" - return SONG_HEADER_SIZE + len(self.song.pitches.data) + self.dictionary + self.streams + return self.encoding.header + len(self.song.pitches.data) + self.dictionary + self.streams @property def dictionary(self) -> int: @@ -101,6 +103,7 @@ def production_encoding( Encoding: The encoding, its streams kept as written. """ return Encoding( + header=SONG_HEADER_SIZE, phrases=len(compressed.phrases), dictionary=compressed.phrases.size, streams=tuple(len(stream) for stream in compressed.streams), diff --git a/src/sampletones_tools/codec/study/packing/coding.py b/src/sampletones_tools/codec/study/packing/coding.py new file mode 100644 index 000000000..580eef3d6 --- /dev/null +++ b/src/sampletones_tools/codec/study/packing/coding.py @@ -0,0 +1,35 @@ +from typing import Protocol + + +class PlaneCoding(Protocol): + """What one byte of a plane stands for: a value its register reads, and the ticks it repeats. + + A plane spends its byte on a value alone or divides it, keeping the value in part of the byte + and counting repeats in the rest. Either way a stream is read the same way — a symbol names a + value and the ticks that value sounds for — so the packing and the parse read a coding through + this and leave how the division is reached to the coding itself. + """ + + @property + def repeats(self) -> int: + """The ticks one symbol covers at most.""" + + @property + def counts(self) -> bool: + """Whether the byte has room to count a repeat.""" + + @property + def seeded(self) -> int: + """The register byte the plane stands at before its first token.""" + + def symbol(self, value: int, repeats: int) -> int: + """The byte the plane writes for ``value`` sounding ``repeats`` ticks running.""" + + def value(self, symbol: int) -> int: + """The register byte ``symbol`` plays.""" + + def repeated(self, symbol: int) -> int: + """The ticks ``symbol`` covers.""" + + def idles(self, plane: bytes) -> bool: + """Whether the plane holds the value it is seeded to throughout, so it takes no stream.""" diff --git a/src/sampletones_tools/codec/study/packing/encode.py b/src/sampletones_tools/codec/study/packing/encode.py new file mode 100644 index 000000000..eed1e3174 --- /dev/null +++ b/src/sampletones_tools/codec/study/packing/encode.py @@ -0,0 +1,115 @@ +from time import process_time +from typing import Final, FrozenSet, Sequence, Tuple + +from sampletones_player.compression.decode import decode_plane +from sampletones_player.compression.dictionary.table import PhraseTable +from sampletones_player.compression.encode import encode_streams +from sampletones_player.compression.options import EVERY_LAYER, CodecOptions +from sampletones_player.specification.song import SONG_HEADER_SIZE +from sampletones_tools.codec.study.corpus.song import StudySong +from sampletones_tools.codec.study.measure import Encoding +from sampletones_tools.codec.study.packing.coding import PlaneCoding +from sampletones_tools.codec.study.packing.scheme import ( + PackingScheme, + plane_codings, + stated_bytes, +) +from sampletones_tools.codec.study.packing.symbols import pack_plane, unpack_plane + +NO_LOOP_BOUNDARIES: Final[FrozenSet[int]] = frozenset() +IDLE_PLANE: Final[bytes] = b"" + + +def packed_symbols( + planes: Sequence[bytes], + codings: Sequence[PlaneCoding], +) -> Tuple[bytes, ...]: + """Each plane as the symbols it plays, a plane holding its seeded value taking none. + + Args: + planes: The planes, in the order the song block writes them. + codings: How each plane's byte divides. + + Returns: + Tuple[bytes, ...]: The symbols per plane, an idle plane's empty so the block leaves it out. + """ + return tuple( + IDLE_PLANE if coding.idles(plane) else pack_plane(plane, coding, boundaries=NO_LOOP_BOUNDARIES) + for plane, coding in zip(planes, codings, strict=True) + ) + + +def plays_back( + planes: Sequence[bytes], + codings: Sequence[PlaneCoding], + symbols: Sequence[bytes], + streams: Sequence[bytes], + table: PhraseTable, +) -> bool: + """Whether the streams read back as the ticks the planes play. + + Args: + planes: The planes, in the order the song block writes them. + codings: How each plane's byte divides. + symbols: The symbols each plane was written from. + streams: The token stream each plane was written as. + table: The dictionary the tokens name. + + Returns: + bool: Whether every plane comes back as it stands. + """ + for plane, coding, packed, stream in zip(planes, codings, symbols, streams, strict=True): + if not stream: + if not coding.idles(plane): + return False + + continue + + if unpack_plane(decode_plane(stream, table, len(packed)), coding) != plane: + return False + + return True + + +def encode_packed( + song: StudySong, + scheme: PackingScheme, + *, + options: CodecOptions = EVERY_LAYER, +) -> Encoding: + """Encodes a song whose planes carry a repeat count in the bits their register leaves alone. + + The planes are packed into symbols and the production codec reads those, so a token's count + names symbols where it named ticks and a hold repeats the symbol the plane reached — the + reach of every token multiplying by what its symbol counts. + + Args: + song: The song to encode. + scheme: Where each plane's division between value and count comes from. + options: Which of the codec's layers the encoding is built from. + + Returns: + Encoding: The encoding, the block's header grown by what the scheme states. + """ + planes = tuple(song.planes.planes) + codings = plane_codings(planes, scheme) + symbols = packed_symbols(planes, codings) + + started = process_time() + table, streams = encode_streams( + symbols, + (), + options=options, + boundaries=(NO_LOOP_BOUNDARIES,) * len(symbols), + ) + seconds = process_time() - started + + return Encoding( + header=SONG_HEADER_SIZE + stated_bytes(codings, scheme), + phrases=len(table), + dictionary=table.size, + streams=tuple(len(stream) for stream in streams), + seconds=seconds, + lossless=plays_back(planes, codings, symbols, streams, table), + written=None, + ) diff --git a/src/sampletones_tools/codec/study/packing/fitted.py b/src/sampletones_tools/codec/study/packing/fitted.py new file mode 100644 index 000000000..31655e358 --- /dev/null +++ b/src/sampletones_tools/codec/study/packing/fitted.py @@ -0,0 +1,74 @@ +from typing import Final, Tuple + +from sampletones_player.specification.binary import MAX_BYTE_VALUE +from sampletones_shared.constants.general import BITS_PER_BYTE +from sampletones_tools.codec.study.packing.form import NO_BITS, WHOLE_BYTE, PlaneForm + +FORM_SIZE: Final[int] = 2 + + +def fitted_form(plane: bytes) -> PlaneForm: + """The narrowest form the plane's own values allow. + + A register fixes some of a byte's bits for every song, and a song fixes more of its own: a + triangle channel names its counter control in every tick, and a bass line reaching only the + low notes leaves the index's top bits standing at zero. Whichever bits the plane never turns + over are free, and the longest run of them carries the repeat count. + + Args: + plane: The values the plane plays. + + Returns: + PlaneForm: The form, a plane turning over every bit taking its whole byte. + """ + if not plane: + return WHOLE_BYTE + + count_mask = _longest_run(_settled_bits(plane)) + return PlaneForm( + value_mask=MAX_BYTE_VALUE ^ count_mask, + value_or=plane[0] & count_mask, + ) + + +def _settled_bits(plane: bytes) -> int: + """The bits every value of the plane agrees on.""" + settled = MAX_BYTE_VALUE + first = plane[0] + for value in plane: + settled &= MAX_BYTE_VALUE ^ (value ^ first) + + return settled + + +def _longest_run(settled: int) -> int: + """The longest run of set bits in ``settled``, as the mask it covers.""" + runs: Tuple[int, ...] = tuple(_runs(settled)) + if not runs: + return NO_BITS + + return max(runs, key=_width) + + +def _runs(settled: int) -> Tuple[int, ...]: + found = [] + run = NO_BITS + for bit in range(BITS_PER_BYTE): + position = 1 << bit + if settled & position: + run |= position + continue + + if run: + found.append(run) + + run = NO_BITS + + if run: + found.append(run) + + return tuple(found) + + +def _width(run: int) -> int: + return bin(run).count("1") diff --git a/src/sampletones_tools/codec/study/packing/grammar.py b/src/sampletones_tools/codec/study/packing/grammar.py new file mode 100644 index 000000000..b92e5b1a7 --- /dev/null +++ b/src/sampletones_tools/codec/study/packing/grammar.py @@ -0,0 +1,101 @@ +from dataclasses import replace +from time import process_time +from typing import Tuple + +from sampletones_player.compression.absent import is_absent +from sampletones_player.compression.compressed import CompressedPlanes +from sampletones_player.compression.encode import encode_streams +from sampletones_player.compression.matches.cache import MatchCache +from sampletones_player.compression.matches.index import PlaneIndex +from sampletones_player.compression.options import EVERY_LAYER +from sampletones_player.compression.planes.order import PlaneOrder +from sampletones_player.specification.song import SONG_HEADER_SIZE +from sampletones_tools.codec.study.corpus.song import StudySong +from sampletones_tools.codec.study.measure import Encoding +from sampletones_tools.codec.study.packing.encode import NO_LOOP_BOUNDARIES, packed_symbols +from sampletones_tools.codec.study.packing.scheme import ( + PackingScheme, + plane_codings, + stated_bytes, +) +from sampletones_tools.codec.study.sandbox.defaults import no_defaults +from sampletones_tools.codec.study.sandbox.encode import encode_grammar +from sampletones_tools.codec.study.sandbox.grammar import BASELINE_GRAMMAR, Grammar +from sampletones_tools.codec.study.sandbox.reference import Reference, plane_parses +from sampletones_tools.codec.study.sandbox.verify import verify_baseline + + +def packed_reference( + song: StudySong, + scheme: PackingScheme, +) -> Tuple[Reference, int]: + """A song's packed planes as every grammar is priced against them. + + The production codec settles a dictionary over the packed symbols, and the sandbox's + baseline grammar is held to the streams it wrote, exactly as it is over a song's own + planes. A grammar is then read on those streams alone. + + Args: + song: The song. + scheme: Where each plane's division between value and count comes from. + + Returns: + Tuple[Reference, int]: What the grammars are priced against, and the bytes the block's + header takes under the scheme. + + Raises: + ValueError: If the baseline grammar prices a packed plane differently from the stream + the codec wrote. + """ + planes = tuple(song.planes.planes) + codings = plane_codings(planes, scheme) + symbols = packed_symbols(planes, codings) + table, streams = encode_streams( + symbols, + (), + options=EVERY_LAYER, + boundaries=(NO_LOOP_BOUNDARIES,) * len(symbols), + ) + compressed = CompressedPlanes( + phrases=table, + streams=PlaneOrder.across(streams), + ticks=song.ticks, + ) + cache = MatchCache(PlaneIndex.from_plane(plane) for plane in symbols if not is_absent(plane)) + baseline = plane_parses(symbols, cache, table, BASELINE_GRAMMAR, no_defaults(len(table))) + verify_baseline(baseline, compressed) + return ( + Reference( + song=song, + planes=symbols, + table=table, + cache=cache, + baseline=baseline, + ), + SONG_HEADER_SIZE + stated_bytes(codings, scheme), + ) + + +def encode_packed_grammar( + song: StudySong, + scheme: PackingScheme, + grammar: Grammar, +) -> Encoding: + """Prices a song's packed planes under a grammar, over the dictionary the codec settled on. + + Args: + song: The song to encode. + scheme: Where each plane's division between value and count comes from. + grammar: The grammar. + + Returns: + Encoding: The bytes each stream would take, timed, and whether the tokens play back. + """ + started = process_time() + reference, header = packed_reference(song, scheme) + priced = encode_grammar(reference, grammar) + return replace( + priced, + header=header, + seconds=process_time() - started, + ) diff --git a/src/sampletones_tools/codec/study/packing/scheme.py b/src/sampletones_tools/codec/study/packing/scheme.py new file mode 100644 index 000000000..21b54817a --- /dev/null +++ b/src/sampletones_tools/codec/study/packing/scheme.py @@ -0,0 +1,75 @@ +from enum import StrEnum +from typing import Sequence, Tuple + +from sampletones_tools.codec.study.packing.coding import PlaneCoding +from sampletones_tools.codec.study.packing.fitted import FORM_SIZE, fitted_form +from sampletones_tools.codec.study.packing.registers import register_forms +from sampletones_tools.codec.study.packing.table import TableForm + + +class PackingScheme(StrEnum): + """Where a plane's division between its value and its repeat count comes from. + + Attributes: + FIXED: The bits the plane's own register leaves alone, which every song shares, so the + block states nothing and the driver knows each plane's mask by the plane it is. + FITTED: The bits the song's own values leave alone, which reaches further and which the + block states as a mask and the bits the register fixes. + TABLE: The plane's distinct values gathered into a table the block states, which reaches + a plane whose values spread across its byte. + """ + + FIXED = "fixed" + FITTED = "fitted" + TABLE = "table" + + +def plane_codings( + planes: Sequence[bytes], + scheme: PackingScheme, +) -> Tuple[PlaneCoding, ...]: + """How each plane's byte divides under a scheme. + + Args: + planes: The planes, in the order the song block writes them. + scheme: Where the division comes from. + + Returns: + Tuple[PlaneCoding, ...]: One coding per plane, in the order given. + """ + match scheme: + case PackingScheme.FIXED: + return tuple(register_forms()) + case PackingScheme.FITTED: + return tuple(fitted_form(plane) for plane in planes) + case PackingScheme.TABLE: + return tuple(_table(plane) for plane in planes) + + +def stated_bytes( + codings: Sequence[PlaneCoding], + scheme: PackingScheme, +) -> int: + """The bytes the song block takes to state how its planes are read. + + Args: + codings: How each plane's byte divides. + scheme: Where the division comes from. + + Returns: + int: The bytes the block states beyond what it states today. + """ + match scheme: + case PackingScheme.FIXED: + return 0 + case PackingScheme.FITTED: + return FORM_SIZE * len(codings) + case PackingScheme.TABLE: + return sum(coding.stated for coding in codings if isinstance(coding, TableForm)) + + +def _table(plane: bytes) -> PlaneCoding: + if not plane: + return fitted_form(plane) + + return TableForm.across(plane) diff --git a/src/sampletones_tools/codec/study/packing/symbols.py b/src/sampletones_tools/codec/study/packing/symbols.py index a6d9f2cf6..f2380ab05 100644 --- a/src/sampletones_tools/codec/study/packing/symbols.py +++ b/src/sampletones_tools/codec/study/packing/symbols.py @@ -1,11 +1,12 @@ from typing import FrozenSet, List, Tuple -from sampletones_tools.codec.study.packing.form import SINGLE_TICK, PlaneForm +from sampletones_tools.codec.study.packing.coding import PlaneCoding +from sampletones_tools.codec.study.packing.form import SINGLE_TICK def pack_plane( plane: bytes, - form: PlaneForm, + form: PlaneCoding, *, boundaries: FrozenSet[int], ) -> bytes: @@ -39,7 +40,7 @@ def pack_plane( def unpack_plane( symbols: bytes, - form: PlaneForm, + form: PlaneCoding, ) -> bytes: """The values a run of symbols plays, one per tick. @@ -59,7 +60,7 @@ def unpack_plane( def symbol_boundaries( plane: bytes, - form: PlaneForm, + form: PlaneCoding, *, boundaries: FrozenSet[int], ) -> FrozenSet[int]: diff --git a/src/sampletones_tools/codec/study/packing/table.py b/src/sampletones_tools/codec/study/packing/table.py new file mode 100644 index 000000000..e405ce71c --- /dev/null +++ b/src/sampletones_tools/codec/study/packing/table.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +from typing import Dict, Final, Tuple + +from pydantic import BaseModel, ConfigDict, model_validator + +from sampletones_player.specification.binary import MAX_BYTE_VALUE, WORD_SIZE +from sampletones_tools.codec.study.packing.form import SINGLE_TICK + +TABLE_LENGTH_SIZE: Final[int] = 1 +TABLE_OFFSET_SIZE: Final[int] = WORD_SIZE + + +class TableForm(BaseModel): + """A plane's distinct values gathered into a table, its byte naming one of them and a count. + + A mask frees only the bits a register leaves alone, and a plane playing a handful of values + spread across its byte frees none — a triangle channel sounding or resting turns over seven + bits to say one thing. Listing what the plane plays gives every such plane a short code, and + the bits the code leaves carry the repeat count. + + The code is the value's place in the table, so the table leads with the value the plane is + seeded to and the driver reaches a register byte by one indexed load. + + Attributes: + values: The register byte each code stands for, in code order. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + values: Tuple[int, ...] + + @model_validator(mode="after") + def _validate_the_table_names_each_value_once(self) -> TableForm: + if not self.values: + raise ValueError("a table names at least one value") + + if len(set(self.values)) != len(self.values): + raise ValueError("a table names each value once, and this one repeats a value") + + outside = tuple(value for value in self.values if not 0 <= value <= MAX_BYTE_VALUE) + if outside: + raise ValueError(f"a table names register bytes, and it names {outside}") + + return self + + @classmethod + def across(cls, plane: bytes) -> TableForm: + """The table a plane's own values fill, in the order they sound from quietest upward. + + Args: + plane: The values the plane plays. + + Returns: + TableForm: The table. + """ + return cls(values=tuple(sorted(set(plane)))) + + @property + def code_bits(self) -> int: + """The bits a code takes.""" + return (len(self.values) - 1).bit_length() + + @property + def value_mask(self) -> int: + """The bits the code occupies.""" + return (1 << self.code_bits) - 1 + + @property + def count_mask(self) -> int: + """The bits a repeat count occupies.""" + return MAX_BYTE_VALUE ^ self.value_mask + + @property + def count_step(self) -> int: + """What one further repeat adds to a symbol.""" + return 1 << self.code_bits + + @property + def repeats(self) -> int: + """The ticks one symbol covers at most.""" + return self.count_mask // self.count_step + 1 if self.count_mask else SINGLE_TICK + + @property + def counts(self) -> bool: + """Whether the plane's byte has room to count a repeat.""" + return self.repeats > SINGLE_TICK + + @property + def seeded(self) -> int: + """The register byte the plane stands at before its first token.""" + return self.values[0] + + @property + def stated(self) -> int: + """The bytes the song block takes to state the table.""" + return TABLE_OFFSET_SIZE + TABLE_LENGTH_SIZE + len(self.values) + + def symbol(self, value: int, repeats: int) -> int: + """The byte the plane writes for ``value`` sounding ``repeats`` ticks running. + + Args: + value: The register byte the ticks play. + repeats: The ticks the value sounds for. + + Returns: + int: The symbol. + + Raises: + ValueError: If the table names no such value, or the repeats reach past what one + symbol counts. + """ + if not SINGLE_TICK <= repeats <= self.repeats: + raise ValueError(f"one symbol counts {SINGLE_TICK} through {self.repeats} ticks, and this counts {repeats}") + + return self._codes[value] | (repeats - SINGLE_TICK) * self.count_step + + def value(self, symbol: int) -> int: + """The register byte ``symbol`` plays.""" + return self.values[symbol & self.value_mask] + + def repeated(self, symbol: int) -> int: + """The ticks ``symbol`` covers.""" + if not self.count_mask: + return SINGLE_TICK + + return (symbol & self.count_mask) // self.count_step + SINGLE_TICK + + def idles(self, plane: bytes) -> bool: + """Whether the plane holds the value it is seeded to throughout, so it takes no stream. + + Args: + plane: The values the plane plays. + + Returns: + bool: Whether the plane leaves its seeded value. + """ + return set(plane) <= {self.seeded} + + @property + def _codes(self) -> Dict[int, int]: + return {value: code for code, value in enumerate(self.values)} diff --git a/src/sampletones_tools/codec/study/sandbox/encode.py b/src/sampletones_tools/codec/study/sandbox/encode.py index 182429300..3f84729d4 100644 --- a/src/sampletones_tools/codec/study/sandbox/encode.py +++ b/src/sampletones_tools/codec/study/sandbox/encode.py @@ -1,6 +1,7 @@ from time import process_time from typing import Final, Sequence, Tuple +from sampletones_player.specification.song import SONG_HEADER_SIZE from sampletones_tools.codec.study.measure import Encoding from sampletones_tools.codec.study.sandbox.defaults import modal_counts, no_defaults from sampletones_tools.codec.study.sandbox.grammar import Grammar @@ -28,11 +29,12 @@ def encode_grammar( parses = _parses(reference, grammar) seconds = process_time() - started return Encoding( + header=SONG_HEADER_SIZE, phrases=len(reference.table), dictionary=grammar.costs.dictionary(reference.table), streams=tuple(parse.size for parse in parses), seconds=seconds, - lossless=plays_back(parses, reference.table, reference.song.planes), + lossless=plays_back(parses, reference.table, reference.planes), written=None, ) diff --git a/src/sampletones_tools/codec/study/sandbox/reference.py b/src/sampletones_tools/codec/study/sandbox/reference.py index b370ff6cf..a7550f986 100644 --- a/src/sampletones_tools/codec/study/sandbox/reference.py +++ b/src/sampletones_tools/codec/study/sandbox/reference.py @@ -35,12 +35,14 @@ class Reference: Attributes: song: The song. + planes: The byte series each stream was written from, in song-block order. table: The dictionary the production codec settled on. cache: What each phrase plays against each written plane, shared by every grammar's parse. baseline: Every plane under the baseline grammar, held to the production streams. """ song: StudySong + planes: Tuple[bytes, ...] table: PhraseTable cache: MatchCache baseline: Tuple[StudyParse, ...] @@ -59,7 +61,7 @@ def parses( Returns: Tuple[StudyParse, ...]: One parse per plane, in song-block order, an absent plane's empty. """ - return _parses(self.song, self.cache, self.table, grammar, defaults) + return plane_parses(self.planes, self.cache, self.table, grammar, defaults) def _contexts( @@ -84,15 +86,15 @@ def _contexts( return tuple(contexts) -def _parses( - song: StudySong, +def plane_parses( + planes: Sequence[bytes], cache: MatchCache, table: PhraseTable, grammar: Grammar, defaults: Sequence[int], ) -> Tuple[StudyParse, ...]: written = iter(parse_plane(context, grammar) for context in _contexts(cache, table, grammar.costs, defaults)) - return tuple(ABSENT_PARSE if is_absent(plane) else next(written) for plane in song.planes.planes) + return tuple(ABSENT_PARSE if is_absent(plane) else next(written) for plane in planes) def reference( @@ -111,12 +113,14 @@ def reference( Raises: ValueError: If the baseline grammar prices a plane differently from the codec's stream. """ - cache = MatchCache(PlaneIndex.from_plane(plane) for plane in song.planes.planes if not is_absent(plane)) + planes = tuple(song.planes.planes) + cache = MatchCache(PlaneIndex.from_plane(plane) for plane in planes if not is_absent(plane)) table = compressed.phrases - baseline = _parses(song, cache, table, BASELINE_GRAMMAR, no_defaults(len(table))) + baseline = plane_parses(planes, cache, table, BASELINE_GRAMMAR, no_defaults(len(table))) verify_baseline(baseline, compressed) return Reference( song=song, + planes=planes, table=table, cache=cache, baseline=baseline, diff --git a/src/sampletones_tools/codec/study/sandbox/verify.py b/src/sampletones_tools/codec/study/sandbox/verify.py index 21e1a7a9c..43c3b68af 100644 --- a/src/sampletones_tools/codec/study/sandbox/verify.py +++ b/src/sampletones_tools/codec/study/sandbox/verify.py @@ -3,7 +3,6 @@ from sampletones_player.compression.compressed import CompressedPlanes from sampletones_player.compression.dictionary.table import PhraseTable from sampletones_player.compression.planes.order import PlaneOrder -from sampletones_player.compression.planes.song import SongPlanes from sampletones_tools.codec.study.sandbox.decode import play_tokens from sampletones_tools.codec.study.sandbox.parse import StudyParse @@ -36,16 +35,16 @@ def verify_baseline( def plays_back( parses: Sequence[StudyParse], table: PhraseTable, - planes: SongPlanes, + planes: Sequence[bytes], ) -> bool: """Whether every plane's tokens play back to the plane they were written from. Args: parses: Every plane's parse, in the order the song block writes them. table: The dictionary the tokens name. - planes: The planes the parses were read from. + planes: The byte series the parses were read from, in song-block order. Returns: bool: Whether the encoding is lossless. """ - return all(play_tokens(parse.tokens, table, len(plane)) == plane for parse, plane in zip(parses, planes.planes)) + return all(play_tokens(parse.tokens, table, len(plane)) == plane for parse, plane in zip(parses, planes)) diff --git a/src/sampletones_tools/codec/study/variants/packing.py b/src/sampletones_tools/codec/study/variants/packing.py new file mode 100644 index 000000000..91061bf7c --- /dev/null +++ b/src/sampletones_tools/codec/study/variants/packing.py @@ -0,0 +1,96 @@ +from dataclasses import replace +from functools import partial +from typing import Final, Tuple + +from sampletones_tools.codec.study.packing.encode import encode_packed +from sampletones_tools.codec.study.packing.grammar import encode_packed_grammar +from sampletones_tools.codec.study.packing.scheme import PackingScheme +from sampletones_tools.codec.study.sandbox.grammar import BASELINE_GRAMMAR, Grammar +from sampletones_tools.codec.study.variants.sandbox import ( + DEFAULT_COUNT, + DEFAULT_COUNT_COSTS, + DEFAULT_COUNT_NOTE, + WIDE_HOLD, + WIDE_HOLD_NOTE, +) +from sampletones_tools.codec.study.variants.variant import Variant, VariantKind + +PLANE_REPEATS: Final[str] = "R1" +DRIVER_NOTE: Final[str] = ( + "a plane's byte carries a repeat count in the bits its register leaves alone; the driver " + "masks the symbol, ors the bits the hardware fixes, and counts a repeat down by subtracting " + "one step, advancing a symbol only where the count runs out" +) +FIXED_NOTE: Final[str] = f"{DRIVER_NOTE}. The masks follow from the plane, so the block states none" +FITTED_NOTE: Final[str] = ( + f"{DRIVER_NOTE}. The block states each plane's mask and the bits its register fixes, " + "two bytes a plane, and the encoder reads them off the plane's own values" +) +TABLE_NOTE: Final[str] = ( + f"{DRIVER_NOTE}, and the value is reached through a table of the plane's own values by one " + "indexed load. The block states each table, and a transposed phrase shifts a code" +) + +PACKING_VARIANTS: Final[Tuple[Variant, ...]] = ( + Variant( + name="packed-fixed", + hypothesis=PLANE_REPEATS, + kind=VariantKind.FORMAT, + note=FIXED_NOTE, + encode=partial(encode_packed, scheme=PackingScheme.FIXED), + needs_seeds=False, + ), + Variant( + name="packed-fitted", + hypothesis=PLANE_REPEATS, + kind=VariantKind.FORMAT, + note=FITTED_NOTE, + encode=partial(encode_packed, scheme=PackingScheme.FITTED), + needs_seeds=False, + ), + Variant( + name="packed-table", + hypothesis=PLANE_REPEATS, + kind=VariantKind.FORMAT, + note=TABLE_NOTE, + encode=partial(encode_packed, scheme=PackingScheme.TABLE), + needs_seeds=False, + ), +) + + +WIDE_HOLD_GRAMMAR: Final[Grammar] = replace(BASELINE_GRAMMAR, wide_hold=True) +GRADUATES_GRAMMAR: Final[Grammar] = replace( + BASELINE_GRAMMAR, + wide_hold=True, + default_counts=True, + costs=DEFAULT_COUNT_COSTS, +) +BEST_SCHEME: Final[PackingScheme] = PackingScheme.TABLE + +COMBINED_VARIANTS: Final[Tuple[Variant, ...]] = ( + Variant( + name="packed-table+wide-hold", + hypothesis=f"{PLANE_REPEATS}+{WIDE_HOLD}", + kind=VariantKind.FORMAT, + note=f"{TABLE_NOTE}. {WIDE_HOLD_NOTE}", + encode=partial(encode_packed_grammar, scheme=BEST_SCHEME, grammar=WIDE_HOLD_GRAMMAR), + needs_seeds=False, + ), + Variant( + name="packed-table+graduates", + hypothesis=f"{PLANE_REPEATS}+{WIDE_HOLD}+{DEFAULT_COUNT}", + kind=VariantKind.FORMAT, + note=f"{TABLE_NOTE}. {WIDE_HOLD_NOTE}; {DEFAULT_COUNT_NOTE}", + encode=partial(encode_packed_grammar, scheme=BEST_SCHEME, grammar=GRADUATES_GRAMMAR), + needs_seeds=False, + ), + Variant( + name="packed-fixed+graduates", + hypothesis=f"{PLANE_REPEATS}+{WIDE_HOLD}+{DEFAULT_COUNT}", + kind=VariantKind.FORMAT, + note=f"{FIXED_NOTE}. {WIDE_HOLD_NOTE}; {DEFAULT_COUNT_NOTE}", + encode=partial(encode_packed_grammar, scheme=PackingScheme.FIXED, grammar=GRADUATES_GRAMMAR), + needs_seeds=False, + ), +) diff --git a/src/sampletones_tools/codec/study/variants/registry.py b/src/sampletones_tools/codec/study/variants/registry.py index 41c2dafcf..d6e4a1b2d 100644 --- a/src/sampletones_tools/codec/study/variants/registry.py +++ b/src/sampletones_tools/codec/study/variants/registry.py @@ -2,6 +2,7 @@ from sampletones_tools.codec.study.variants.baselines import Baselines, baseline_variant from sampletones_tools.codec.study.variants.layouts import LAYOUT_VARIANTS +from sampletones_tools.codec.study.variants.packing import COMBINED_VARIANTS, PACKING_VARIANTS from sampletones_tools.codec.study.variants.production import BASELINE_NAME, BUDGET_VARIANTS, SEED_VARIANTS from sampletones_tools.codec.study.variants.sandbox import grammar_variants from sampletones_tools.codec.study.variants.variant import Variant @@ -24,6 +25,8 @@ def variants(baselines: Baselines) -> Dict[str, Variant]: *BUDGET_VARIANTS, *grammar_variants(baselines), *LAYOUT_VARIANTS, + *PACKING_VARIANTS, + *COMBINED_VARIANTS, ) return {variant.name: variant for variant in every} diff --git a/tests/integration/tooling/test_codec_study.py b/tests/integration/tooling/test_codec_study.py index add93f6cf..eb8449034 100644 --- a/tests/integration/tooling/test_codec_study.py +++ b/tests/integration/tooling/test_codec_study.py @@ -16,6 +16,7 @@ from sampletones_tools.codec.study.session import run_study VARIANT: Final[str] = "wide-hold" +PACKED_VARIANT: Final[str] = "packed-table" LENGTHEN_SECONDS: Final[int] = 2 @@ -56,6 +57,29 @@ def test_a_run_writes_its_tables_and_the_manifest_that_repeats_it(self, plan: St start = markdown.index(table[0]) assert markdown[start : start + len(table)] == table + def test_a_packed_plane_run_reads_back_as_the_song_it_was_written_from( + self, + integration_project: Project, + tmp_path: Path, + ) -> None: + """A run refuses a variant whose streams do not play back, so completing is the assertion.""" + project = tmp_path / "packed.stp" + ProjectContainer.save(integration_project, project) + plan = plan_study( + StudyManifest( + projects=(StudySource.at(project),), + reconstructions=(), + lengthen_seconds=LENGTHEN_SECONDS, + variants=(PACKED_VARIANT,), + ) + ) + + directory = run_study(plan, tmp_path / "run") + + header, *measured = _read_csv(directory / REPORT_CSV) + variant = header.index("variant") + assert {row[variant] for row in measured} >= {PACKED_VARIANT} + def test_a_source_that_fails_to_read_leaves_the_output_untouched(self, tmp_path: Path) -> None: broken = tmp_path / "broken.stp" broken.write_bytes(b"") diff --git a/tests/unit/sampletones_tools/codec/study/test_packing.py b/tests/unit/sampletones_tools/codec/study/test_packing.py index da9d1d3a8..c61b81d58 100644 --- a/tests/unit/sampletones_tools/codec/study/test_packing.py +++ b/tests/unit/sampletones_tools/codec/study/test_packing.py @@ -7,6 +7,7 @@ from sampletones_player.compression.planes.order import PlaneOrder from sampletones_player.specification.binary import MAX_BYTE_VALUE from sampletones_player.specification.registers import SUSTAINED_LEVEL +from sampletones_tools.codec.study.packing.fitted import FORM_SIZE, fitted_form from sampletones_tools.codec.study.packing.form import NO_BITS, WHOLE_BYTE, PlaneForm from sampletones_tools.codec.study.packing.registers import ( NOISE_CONTROL_FORM, @@ -14,11 +15,21 @@ PULSE_CONTROL_FORM, register_forms, ) +from sampletones_tools.codec.study.packing.scheme import ( + PackingScheme, + plane_codings, + stated_bytes, +) from sampletones_tools.codec.study.packing.symbols import ( pack_plane, symbol_boundaries, unpack_plane, ) +from sampletones_tools.codec.study.packing.table import ( + TABLE_LENGTH_SIZE, + TABLE_OFFSET_SIZE, + TableForm, +) NO_BOUNDARIES: Final[FrozenSet[int]] = frozenset() QUIET: Final[int] = SUSTAINED_LEVEL @@ -170,3 +181,120 @@ def _ticks_before(packed: bytes, starts: FrozenSet[int]) -> FrozenSet[int]: sum(NOISE_CONTROL_FORM.repeated(symbol) for symbol in packed[:start]) for start in sorted(starts) ) return frozenset(reached) + + +class TestTheNarrowestFormAPlaneAllows: + def test_a_plane_naming_a_control_bit_in_every_tick_frees_it(self) -> None: + plane = bytes([0x80, 0xFF, 0x80, 0xFF]) + + form = fitted_form(plane) + + assert form.count_mask == 0x80 + assert form.repeats == 2 + assert form.seeded == 0x80 + + def test_a_plane_reaching_only_low_values_frees_the_bits_above_them(self) -> None: + plane = bytes([0x00, 0x03, 0x1B, 0x07]) + + form = fitted_form(plane) + + assert form.repeats == 8 + assert form.fits(plane) + + def test_a_plane_turning_over_every_bit_takes_its_whole_byte(self) -> None: + plane = bytes(range(MAX_BYTE_VALUE + 1)) + + assert fitted_form(plane) == WHOLE_BYTE + + def test_a_plane_of_one_value_frees_its_whole_byte(self) -> None: + plane = bytes([SUSTAINED_LEVEL] * 4) + + form = fitted_form(plane) + + assert form.seeded == SUSTAINED_LEVEL + assert form.idles(plane) + + def test_a_plane_of_no_ticks_takes_its_whole_byte(self) -> None: + assert fitted_form(b"") == WHOLE_BYTE + + def test_the_fitted_form_reaches_at_least_as_far_as_the_register_fixes(self) -> None: + plane = _noise_control(0, 1, 2, MAX_VOLUME) + + assert fitted_form(plane).repeats >= NOISE_CONTROL_FORM.repeats + + +class TestAPlanesValuesGatheredIntoATable: + def test_a_code_stands_for_the_value_it_names(self) -> None: + plane = bytes([0x80, 0xFF, 0x80]) + + table = TableForm.across(plane) + + assert table.values == (0x80, 0xFF) + assert table.value(table.symbol(0xFF, repeats=1)) == 0xFF + + def test_two_values_leave_seven_bits_to_count_with(self) -> None: + table = TableForm.across(bytes([0x80, 0xFF])) + + assert table.code_bits == 1 + assert table.repeats == 128 + + def test_the_table_leads_with_the_value_the_plane_is_seeded_to(self) -> None: + table = TableForm.across(bytes([0xFF, 0x80, 0xFF])) + + assert table.seeded == 0x80 + + def test_the_ticks_come_back_as_they_were_written(self) -> None: + plane = bytes([0x80] * 300 + [0xFF] * 5 + [0x80]) + + packed = pack_plane(plane, TableForm.across(plane), boundaries=NO_BOUNDARIES) + + assert unpack_plane(packed, TableForm.across(plane)) == plane + + def test_the_block_states_the_table_it_reads_through(self) -> None: + table = TableForm.across(bytes(range(16))) + + assert table.stated == TABLE_OFFSET_SIZE + TABLE_LENGTH_SIZE + 16 + + def test_a_table_naming_a_value_twice_is_refused(self) -> None: + with pytest.raises(ValueError): + TableForm(values=(1, 1)) + + def test_a_table_naming_no_value_is_refused(self) -> None: + with pytest.raises(ValueError): + TableForm(values=()) + + +class TestWhatASchemeStates: + def test_the_register_scheme_states_nothing(self) -> None: + planes = (_noise_control(0, 1),) * len(PlaneOrder.names()) + + codings = plane_codings(planes, PackingScheme.FIXED) + + assert stated_bytes(codings, PackingScheme.FIXED) == 0 + + def test_the_fitted_scheme_states_a_form_per_plane(self) -> None: + planes = (_noise_control(0, 1),) * len(PlaneOrder.names()) + + codings = plane_codings(planes, PackingScheme.FITTED) + + assert stated_bytes(codings, PackingScheme.FITTED) == FORM_SIZE * len(planes) + + def test_the_table_scheme_states_every_table(self) -> None: + planes = (bytes([0x80, 0xFF]),) * len(PlaneOrder.names()) + + codings = plane_codings(planes, PackingScheme.TABLE) + + assert stated_bytes(codings, PackingScheme.TABLE) == sum(coding.stated for coding in codings) + + def test_every_scheme_reads_back_the_ticks_it_packs(self) -> None: + planes = ( + _noise_control(0, 0, 3, 3, 3, MAX_VOLUME), + bytes([0x80, 0x80, 0xFF, 0x80]), + bytes(range(8)), + ) + for scheme in PackingScheme: + codings = plane_codings(planes, scheme)[: len(planes)] + for plane, coding in zip(planes, codings): + packed = pack_plane(plane, coding, boundaries=NO_BOUNDARIES) + + assert unpack_plane(packed, coding) == plane diff --git a/tests/unit/sampletones_tools/codec/study/test_verdicts.py b/tests/unit/sampletones_tools/codec/study/test_verdicts.py index d7ac7f024..d9c224d8a 100644 --- a/tests/unit/sampletones_tools/codec/study/test_verdicts.py +++ b/tests/unit/sampletones_tools/codec/study/test_verdicts.py @@ -8,6 +8,7 @@ from sampletones_player.compression.planes.order import PlaneOrder from sampletones_player.compression.planes.song import SongPlanes from sampletones_player.specification.compression import PLANE_COUNT +from sampletones_player.specification.song import SONG_HEADER_SIZE from sampletones_shared.music import Tuning from sampletones_tools.codec.study.corpus.song import SongGroup, StudySong from sampletones_tools.codec.study.measure import Encoding, Measurement @@ -117,6 +118,7 @@ def _measurement( song=song, variant=variant, encoding=Encoding( + header=SONG_HEADER_SIZE, phrases=0, dictionary=0, streams=(streams,) + (0,) * (PLANE_COUNT - 1), @@ -134,6 +136,7 @@ def _variant(name: str, kind: VariantKind) -> Variant: kind=kind, note="", encode=lambda song: Encoding( + header=SONG_HEADER_SIZE, phrases=0, dictionary=0, streams=(), From 97e79787ca001c05e324b4e2e7095a40d35581af Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 21 Sep 2026 12:51:13 +0200 Subject: [PATCH 03/11] Stated: a song's planes once, in the table every reader derives from --- src/sampletones_core/constants/enums.py | 5 +- src/sampletones_player/compression/decode.py | 43 +++--- .../compression/matches/match.py | 15 -- .../compression/matches/matcher.py | 17 ++- .../compression/planes/channel.py | 94 ------------ .../compression/planes/order.py | 13 +- .../compression/planes/rebuild.py | 44 +++--- .../compression/planes/separate.py | 37 ++--- .../compression/planes/song.py | 136 +++++++----------- src/sampletones_player/compression/seeds.py | 2 +- .../specification/compression.py | 6 - .../specification/planes.py | 98 +++++++++++++ src/sampletones_player/specification/song.py | 2 +- .../codec/report/encoding.py | 18 +-- src/sampletones_tools/codec/report/session.py | 3 +- .../codec/study/accounting/fixed.py | 11 +- .../codec/study/corpus/song.py | 5 +- .../codec/study/layouts/planes.py | 16 +-- .../codec/study/layouts/tone.py | 10 +- tests/integration/nsf/test_driver_bend.py | 8 +- tests/integration/nsf/test_nsf_pipeline.py | 2 +- tests/suite/player.py | 38 +++-- tests/suite/study.py | 11 +- .../compression/matches/test_matcher.py | 3 +- .../compression/planes/test_channel.py | 21 --- .../compression/planes/test_order.py | 2 +- .../compression/planes/test_separate.py | 18 +-- .../compression/planes/test_song.py | 66 ++++----- .../compression/test_encode.py | 8 +- .../compression/test_seeds.py | 11 +- .../sampletones_player/nsf/test_layout.py | 2 +- .../unit/sampletones_player/nsf/test_song.py | 2 +- .../specification/test_planes.py | 51 +++++++ .../codec/study/test_layouts.py | 14 +- .../codec/study/test_sandbox.py | 4 +- .../codec/study/test_variants.py | 2 +- .../codec/study/test_verdicts.py | 2 +- .../player/test_song_include.py | 2 +- 38 files changed, 426 insertions(+), 416 deletions(-) delete mode 100644 src/sampletones_player/compression/matches/match.py delete mode 100644 src/sampletones_player/compression/planes/channel.py create mode 100644 src/sampletones_player/specification/planes.py delete mode 100644 tests/unit/sampletones_player/compression/planes/test_channel.py create mode 100644 tests/unit/sampletones_player/specification/test_planes.py diff --git a/src/sampletones_core/constants/enums.py b/src/sampletones_core/constants/enums.py index cb1006f41..c23a12ee5 100644 --- a/src/sampletones_core/constants/enums.py +++ b/src/sampletones_core/constants/enums.py @@ -91,14 +91,15 @@ class CQTWindow(StrEnum): ALL_CHANNELS: Final[FrozenSet[ChannelName]] = frozenset(ChannelName.items()) -TONE_CHANNELS: Final[FrozenSet[ChannelName]] = frozenset( +PULSE_CHANNELS: Final[FrozenSet[ChannelName]] = frozenset( { ChannelName.PULSE1, ChannelName.PULSE2, - ChannelName.TRIANGLE, } ) +TONE_CHANNELS: Final[FrozenSet[ChannelName]] = PULSE_CHANNELS | {ChannelName.TRIANGLE} + CHANNEL_ABBREVIATIONS: Final[Dict[ChannelName, Literal["P", "p", "T", "N"]]] = { ChannelName.PULSE1: "P", diff --git a/src/sampletones_player/compression/decode.py b/src/sampletones_player/compression/decode.py index 2dc0a8848..bd3d998b4 100644 --- a/src/sampletones_player/compression/decode.py +++ b/src/sampletones_player/compression/decode.py @@ -1,4 +1,4 @@ -from typing import Tuple +from typing import List, Tuple from sampletones_player.compression.compressed import CompressedPlanes from sampletones_player.compression.dictionary.table import PhraseTable @@ -13,6 +13,11 @@ TOKEN_TAG_MASK, TokenTag, ) +from sampletones_player.specification.planes import ( + PLANES, + PlaneRole, + plane_index, +) def _phrase_values( @@ -95,21 +100,6 @@ def decode_plane(data: bytes, table: PhraseTable, ticks: int) -> bytes: return bytes(values[:ticks]) -def _tone_planes( - compressed: CompressedPlanes, - control: bytes, - value: bytes, - bend: bytes, -) -> Tuple[bytes, bytes, bytes]: - """A tone channel's planes played back, its bend plane as long as its value plane flags.""" - played = decode_plane(value, compressed.phrases, compressed.ticks) - return ( - decode_plane(control, compressed.phrases, compressed.ticks), - played, - decode_plane(bend, compressed.phrases, flagged_ticks(played)), - ) - - def decode_planes(compressed: CompressedPlanes) -> SongPlanes: """Plays a song's token streams back into the planes they were written from. @@ -120,16 +110,15 @@ def decode_planes(compressed: CompressedPlanes) -> SongPlanes: compressed: The dictionary, the streams and the ticks the song lasts. Returns: - SongPlanes: The planes under the channel each belongs to. + SongPlanes: Every plane, in the order the song block writes them. """ - streams = compressed.streams - played = PlaneOrder.across( - ( - *_tone_planes(compressed, streams.pulse1_control, streams.pulse1_value, streams.pulse1_bend), - *_tone_planes(compressed, streams.pulse2_control, streams.pulse2_value, streams.pulse2_bend), - *_tone_planes(compressed, streams.triangle_control, streams.triangle_value, streams.triangle_bend), - decode_plane(streams.noise_control, compressed.phrases, compressed.ticks), - decode_plane(streams.noise_value, compressed.phrases, compressed.ticks), + played: List[bytes] = [] + for plane, stream in zip(PLANES, compressed.streams, strict=True): + reach = ( + flagged_ticks(played[plane_index(plane.channel, PlaneRole.VALUE)]) + if plane.spans_flagged_ticks + else compressed.ticks ) - ) - return SongPlanes.from_order(played) + played.append(decode_plane(stream, compressed.phrases, reach)) + + return SongPlanes(planes=PlaneOrder.across(played)) diff --git a/src/sampletones_player/compression/matches/match.py b/src/sampletones_player/compression/matches/match.py deleted file mode 100644 index ebfd4c4c9..000000000 --- a/src/sampletones_player/compression/matches/match.py +++ /dev/null @@ -1,15 +0,0 @@ -from typing import NamedTuple - - -class PhraseMatch(NamedTuple): - """A phrase the plane plays from a tick, and the terms it plays it on. - - Attributes: - phrase_id: Position the phrase takes in the table. - ticks: The ticks the plane plays of it, its final value held past its end. - transpose: The shift every byte of it is played at. - """ - - phrase_id: int - ticks: int - transpose: int diff --git a/src/sampletones_player/compression/matches/matcher.py b/src/sampletones_player/compression/matches/matcher.py index 89e772271..e1f8a013d 100644 --- a/src/sampletones_player/compression/matches/matcher.py +++ b/src/sampletones_player/compression/matches/matcher.py @@ -1,13 +1,26 @@ from itertools import chain -from typing import Dict, Iterator, List, Sequence, Tuple +from typing import Dict, Iterator, List, NamedTuple, Sequence, Tuple from sampletones_player.compression.dictionary.table import PhraseTable from sampletones_player.compression.matches.cache import KEY_LENGTH, MIN_PHRASE_TICKS, MatchCache from sampletones_player.compression.matches.index import PlaneIndex -from sampletones_player.compression.matches.match import PhraseMatch from sampletones_player.specification.binary import BYTE_VALUES +class PhraseMatch(NamedTuple): + """A phrase the plane plays from a tick, and the terms it plays it on. + + Attributes: + phrase_id: Position the phrase takes in the table. + ticks: The ticks the plane plays of it, its final value held past its end. + transpose: The shift every byte of it is played at. + """ + + phrase_id: int + ticks: int + transpose: int + + class PhraseMatcher: """Answers which phrases one plane plays at a tick, and for how many ticks. diff --git a/src/sampletones_player/compression/planes/channel.py b/src/sampletones_player/compression/planes/channel.py deleted file mode 100644 index db5fc91ad..000000000 --- a/src/sampletones_player/compression/planes/channel.py +++ /dev/null @@ -1,94 +0,0 @@ -from __future__ import annotations - -from typing import Tuple - -from pydantic import BaseModel, ConfigDict, model_validator - -from sampletones_player.compression.planes.flags import flagged_ticks - - -class ChannelPlanes(BaseModel): - """One channel's ticks separated into the byte series it writes. - - A channel writes two things each tick: how it sounds and what it sounds. Read tick by tick - those two braid together, and each turns over at its own pace — a volume envelope decays - while a pitch holds, a pitch walks while the timbre stays put. Kept apart, each is a series - that repeats and rests on its own terms, which is the form the codec reads them in. - - Attributes: - control: The timbre byte each tick writes, volume riding in it where a channel has one. - value: The pitch each tick sounds, as an index into the pitch table, or the noise - channel's period byte. - """ - - model_config = ConfigDict(extra="forbid", frozen=True) - - control: bytes - value: bytes - - @model_validator(mode="after") - def _validate_every_plane_covers_the_same_ticks(self) -> ChannelPlanes: - if len(self.control) != len(self.value): - raise ValueError( - f"a channel's control and value cover the same ticks, and these cover " - f"{len(self.control)} and {len(self.value)}" - ) - - if not self.control: - raise ValueError("a channel's planes cover at least one tick") - - return self - - @property - def ticks(self) -> int: - """The ticks the channel's planes cover.""" - return len(self.control) - - @property - def ordered(self) -> Tuple[bytes, ...]: - """The channel's planes, in the order the song block writes them.""" - return (self.control, self.value) - - -class TonePlanes(ChannelPlanes): - """A tone channel's ticks, the divider each one sounds at named in two parts. - - A tone channel reaches its divider through the pitch table, and a frame may stand away from - the note it names. The value plane holds the note, which is what lets a phrase be transposed - by adding to it, and the bend plane holds how far the frame stands from it — so the divider - the hardware takes is the sum, and each half repeats on its own terms. - - The bend plane is read on the ticks the value plane flags and on those alone: a tick left - unflagged sounds its note's own divider. A channel that never bends therefore holds an empty - bend plane, and one that bends holds a value only where a note does. - - Attributes: - bend: The divider steps each flagged tick stands away from its note, held as signed bytes - in the order the flagged ticks come. - """ - - bend: bytes - - @model_validator(mode="after") - def _validate_the_bend_covers_the_flagged_ticks(self) -> TonePlanes: - flagged = flagged_ticks(self.value) - if len(self.bend) != flagged: - raise ValueError(f"a bend plane holds a value per flagged tick, {flagged}, and this holds {len(self.bend)}") - - return self - - def bend_position(self, tick: int) -> int: - """Where in the bend plane the song stands once ``tick`` ticks have played. - - Args: - tick: The ticks played. - - Returns: - int: The bend values those ticks read. - """ - return flagged_ticks(self.value[:tick]) - - @property - def ordered(self) -> Tuple[bytes, ...]: - """The channel's planes, in the order the song block writes them.""" - return (self.control, self.value, self.bend) diff --git a/src/sampletones_player/compression/planes/order.py b/src/sampletones_player/compression/planes/order.py index 2291484af..a15c79fb3 100644 --- a/src/sampletones_player/compression/planes/order.py +++ b/src/sampletones_player/compression/planes/order.py @@ -2,7 +2,7 @@ from typing import Iterable, NamedTuple, Tuple -from sampletones_player.specification.compression import PLANE_COUNT +from sampletones_player.specification.planes import PLANE_COUNT, PLANE_NAMES class PlaneOrder(NamedTuple): @@ -12,6 +12,10 @@ class PlaneOrder(NamedTuple): shape: the values each plane plays tick by tick, and the tokens those values are written as. Naming them is what lets either be carried whole and read back by the channel it belongs to. + The fields spell out what ``specification.planes.PLANES`` states, so that a reader reaches a + plane by its own name and a type checker knows which names there are. A test holds the two + in step. + Attributes: pulse1_control: The first pulse channel's timbre and volume. pulse1_value: The first pulse channel's pitch. @@ -41,10 +45,13 @@ class PlaneOrder(NamedTuple): @classmethod def names(cls) -> Tuple[str, ...]: """The planes' names, in the order the song block writes them.""" - return cls._fields + return PLANE_NAMES @classmethod - def across(cls, planes: Iterable[bytes]) -> PlaneOrder: + def across( + cls, + planes: Iterable[bytes], + ) -> PlaneOrder: """Gathers a song's planes under the names the song block writes them by. Args: diff --git a/src/sampletones_player/compression/planes/rebuild.py b/src/sampletones_player/compression/planes/rebuild.py index 057174453..0de1f56e3 100644 --- a/src/sampletones_player/compression/planes/rebuild.py +++ b/src/sampletones_player/compression/planes/rebuild.py @@ -1,7 +1,7 @@ from typing import Iterator, Tuple +from sampletones_core.constants.enums import ChannelName from sampletones_player.compression.pitch import PitchTable -from sampletones_player.compression.planes.channel import ChannelPlanes, TonePlanes from sampletones_player.compression.planes.flags import is_flagged, pitch_index from sampletones_player.compression.planes.song import SongPlanes from sampletones_player.registers.noise import NoiseRegisters @@ -16,7 +16,8 @@ def tone_dividers( - planes: TonePlanes, + value: bytes, + bend: bytes, timers: Tuple[int, ...], ) -> Tuple[int, ...]: """The divider each tick of a tone channel reaches, its note and its bend together. @@ -26,32 +27,32 @@ def tone_dividers( other tick sounds its pitch's own divider. Args: - planes: The channel's planes. + value: The pitch each tick names, under the flag saying whether it bends. + bend: The steps each flagged tick stands from its pitch's own divider. timers: The divider each pitch sounds at, in pitch order. Returns: Tuple[int, ...]: One divider per tick. """ - bends = iter(planes.bend) - return tuple( - timers[pitch_index(value)] + (signed_byte(next(bends)) if is_flagged(value) else 0) for value in planes.value - ) + bends = iter(bend) + return tuple(timers[pitch_index(named)] + (signed_byte(next(bends)) if is_flagged(named) else 0) for named in value) def _sounded( - planes: TonePlanes, + planes: Tuple[bytes, ...], pitches: PitchTable, ) -> Iterator[Tuple[int, int, int]]: """Each tick's control byte, the divider its registers carry, and the pitch it is counted from.""" + control, value, bend = planes yield from zip( - planes.control, - tone_dividers(planes, pitches.timers), - (pitches.pitch(pitch_index(value)) for value in planes.value), + control, + tone_dividers(value, bend, pitches.timers), + (pitches.pitch(pitch_index(named)) for named in value), ) def _pulse_registers( - planes: TonePlanes, + planes: Tuple[bytes, ...], pitches: PitchTable, ) -> Tuple[PulseRegisters, ...]: return tuple( @@ -66,7 +67,7 @@ def _pulse_registers( def _triangle_registers( - planes: TonePlanes, + planes: Tuple[bytes, ...], pitches: PitchTable, ) -> Tuple[TriangleRegisters, ...]: return tuple( @@ -80,13 +81,14 @@ def _triangle_registers( ) -def _noise_registers(planes: ChannelPlanes) -> Tuple[NoiseRegisters, ...]: +def _noise_registers(planes: Tuple[bytes, ...]) -> Tuple[NoiseRegisters, ...]: + control, value = planes return tuple( NoiseRegisters( - control=control, + control=timbre, period=period, ) - for control, period in zip(planes.control, planes.value) + for timbre, period in zip(control, value) ) @@ -97,15 +99,15 @@ def streams_from_planes( """Rebuilds a song's four streams from the planes they were separated into. Args: - planes: The planes under the channel each belongs to. + planes: Every plane, in the order the song block writes them. pitches: The timer each pitch sounds at. Returns: ChannelStreams: The per-tick register values every channel plays. """ return ChannelStreams( - pulse1=_pulse_registers(planes.pulse1, pitches), - pulse2=_pulse_registers(planes.pulse2, pitches), - triangle=_triangle_registers(planes.triangle, pitches), - noise=_noise_registers(planes.noise), + pulse1=_pulse_registers(planes.of(ChannelName.PULSE1), pitches), + pulse2=_pulse_registers(planes.of(ChannelName.PULSE2), pitches), + triangle=_triangle_registers(planes.of(ChannelName.TRIANGLE), pitches), + noise=_noise_registers(planes.of(ChannelName.NOISE)), ) diff --git a/src/sampletones_player/compression/planes/separate.py b/src/sampletones_player/compression/planes/separate.py index e2ca3497a..2cdb40c3a 100644 --- a/src/sampletones_player/compression/planes/separate.py +++ b/src/sampletones_player/compression/planes/separate.py @@ -1,9 +1,9 @@ -from typing import Final, List, Sequence +from typing import Final, List, Sequence, Tuple from sampletones_core.constants.enums import TONE_CHANNELS, ChannelName from sampletones_player.compression.pitch import PitchTable -from sampletones_player.compression.planes.channel import ChannelPlanes, TonePlanes from sampletones_player.compression.planes.flags import flagged_value, note_flags +from sampletones_player.compression.planes.order import PlaneOrder from sampletones_player.compression.planes.song import SongPlanes from sampletones_player.registers.base import ChannelRegisters from sampletones_player.registers.streams import ChannelStreams @@ -29,29 +29,30 @@ def _tone_ticks(registers: Sequence[ChannelRegisters]) -> List[ToneRegisters]: def _tone_planes( registers: Sequence[ChannelRegisters], pitches: PitchTable, -) -> TonePlanes: +) -> Tuple[bytes, ...]: ticks = _tone_ticks(registers) indices = [pitches.index(tick.anchor) for tick in ticks] offsets = [tick.divider - pitches.timers[index] for tick, index in zip(ticks, indices)] flags = note_flags(indices, offsets) - return TonePlanes( - control=bytes(tick.values[CONTROL_VALUE_INDEX] for tick in ticks), - value=bytes(flagged_value(index, flag) for index, flag in zip(indices, flags)), - bend=bytes(unsigned_byte(offset) for offset, flag in zip(offsets, flags) if flag), + return ( + bytes(tick.values[CONTROL_VALUE_INDEX] for tick in ticks), + bytes(flagged_value(index, flag) for index, flag in zip(indices, flags)), + bytes(unsigned_byte(offset) for offset, flag in zip(offsets, flags) if flag), ) -def _noise_planes(registers: Sequence[ChannelRegisters]) -> ChannelPlanes: - control = bytes(tick.values[CONTROL_VALUE_INDEX] for tick in registers) - value = bytes(tick.values[FIRST_VALUE_INDEX] for tick in registers) - return ChannelPlanes(control=control, value=value) +def _noise_planes(registers: Sequence[ChannelRegisters]) -> Tuple[bytes, ...]: + return ( + bytes(tick.values[CONTROL_VALUE_INDEX] for tick in registers), + bytes(tick.values[FIRST_VALUE_INDEX] for tick in registers), + ) def channel_planes( channel: ChannelName, registers: Sequence[ChannelRegisters], pitches: PitchTable, -) -> ChannelPlanes: +) -> Tuple[bytes, ...]: """Separates one channel's ticks into the planes the codec reads. A tone channel's value plane names the pitch each divider is counted from, and its bend plane @@ -64,7 +65,7 @@ def channel_planes( pitches: The timer each pitch sounds at. Returns: - ChannelPlanes: The channel's own planes. + Tuple[bytes, ...]: The channel's own planes, in the order the song block writes them. Raises: TypeError: If a tone channel's ticks hold registers another channel writes. @@ -97,10 +98,10 @@ def planes_from_streams( ValueError: If a tone channel's divider lies further from the pitch it is counted from than a signed byte states. """ - pulse1, pulse2, triangle, noise = streams.padded return SongPlanes( - pulse1=_tone_planes(pulse1, pitches), - pulse2=_tone_planes(pulse2, pitches), - triangle=_tone_planes(triangle, pitches), - noise=_noise_planes(noise), + planes=PlaneOrder.across( + plane + for channel, registers in zip(ChannelName.items(), streams.padded, strict=True) + for plane in channel_planes(channel, registers, pitches) + ) ) diff --git a/src/sampletones_player/compression/planes/song.py b/src/sampletones_player/compression/planes/song.py index 3e7ba5ecf..a4754a55c 100644 --- a/src/sampletones_player/compression/planes/song.py +++ b/src/sampletones_player/compression/planes/song.py @@ -1,99 +1,76 @@ from __future__ import annotations -from typing import Tuple +from typing import Final, Tuple from pydantic import BaseModel, ConfigDict, model_validator -from sampletones_player.compression.planes.channel import ChannelPlanes, TonePlanes +from sampletones_core.constants.enums import ChannelName +from sampletones_player.compression.planes.flags import flagged_ticks from sampletones_player.compression.planes.order import PlaneOrder +from sampletones_player.specification.planes import ( + PLANES, + PlaneRole, + channel_indices, + plane_index, +) + +FIRST_PLANE: Final[int] = 0 class SongPlanes(BaseModel): - """Every channel of a song separated into planes, the whole of what the codec compresses. + """Every plane of a song, in the order the song block writes them. + + A channel writes what it sounds and how it sounds it as separate byte series. Read tick by + tick those braid together, and each turns over at its own pace — a volume envelope decays + while a pitch holds, a pitch walks while the timbre stays put. Kept apart, each is a series + that repeats and rests on its own terms, which is the form the codec reads them in. + + A bend plane holds a value on the ticks its channel's value plane flags and on those alone, + so it covers fewer ticks than the song lasts while every other plane covers them all. Attributes: - pulse1: The first pulse channel's planes. - pulse2: The second pulse channel's planes. - triangle: The triangle channel's planes. - noise: The noise channel's planes. + planes: The byte series each plane plays, under the name the song block writes it by. """ model_config = ConfigDict(extra="forbid", frozen=True) - pulse1: TonePlanes - pulse2: TonePlanes - triangle: TonePlanes - noise: ChannelPlanes - - @classmethod - def from_order(cls, planes: PlaneOrder) -> SongPlanes: - """Gathers a song block's planes back into the four channels that write them. - - Args: - planes: The planes, in the order the song block writes them. - - Returns: - SongPlanes: The planes under the channel each belongs to. - """ - return cls( - pulse1=TonePlanes( - control=planes.pulse1_control, - value=planes.pulse1_value, - bend=planes.pulse1_bend, - ), - pulse2=TonePlanes( - control=planes.pulse2_control, - value=planes.pulse2_value, - bend=planes.pulse2_bend, - ), - triangle=TonePlanes( - control=planes.triangle_control, - value=planes.triangle_value, - bend=planes.triangle_bend, - ), - noise=ChannelPlanes( - control=planes.noise_control, - value=planes.noise_value, - ), - ) + planes: PlaneOrder @model_validator(mode="after") - def _validate_every_channel_reaches_the_same_tick(self) -> SongPlanes: - lengths = {channels.ticks for channels in self.ordered} - if len(lengths) > 1: - raise ValueError(f"a song's channels cover the same ticks, and these cover {sorted(lengths)}") + def _validate_every_plane_covers_the_song(self) -> SongPlanes: + if not self.planes[FIRST_PLANE]: + raise ValueError("a song's planes cover at least one tick") - return self + for plane, played in zip(PLANES, self.planes, strict=True): + reach = self._flagged(plane.channel) if plane.spans_flagged_ticks else self.ticks + if len(played) != reach: + raise ValueError( + f"the {plane.name} plane covers {reach} of the song's values, and this covers {len(played)}" + ) - @property - def ordered(self) -> Tuple[ChannelPlanes, ...]: - """The four channels in the order the generator names run.""" - return (self.pulse1, self.pulse2, self.triangle, self.noise) + return self - @property - def planes(self) -> PlaneOrder: - """Every plane in the order the song block writes them.""" - return PlaneOrder( - pulse1_control=self.pulse1.control, - pulse1_value=self.pulse1.value, - pulse1_bend=self.pulse1.bend, - pulse2_control=self.pulse2.control, - pulse2_value=self.pulse2.value, - pulse2_bend=self.pulse2.bend, - triangle_control=self.triangle.control, - triangle_value=self.triangle.value, - triangle_bend=self.triangle.bend, - noise_control=self.noise.control, - noise_value=self.noise.value, - ) + def _flagged(self, channel: ChannelName) -> int: + return flagged_ticks(self.planes[plane_index(channel, PlaneRole.VALUE)]) @property def ticks(self) -> int: """The ticks the song lasts.""" - return self.pulse1.ticks + return len(self.planes[FIRST_PLANE]) + + def of(self, channel: ChannelName) -> Tuple[bytes, ...]: + """The planes ``channel`` writes, in the order the song block writes them. + + Args: + channel: The channel to read. + + Returns: + Tuple[bytes, ...]: That channel's own planes. + """ + return tuple(self.planes[index] for index in channel_indices(channel)) def positions(self, tick: int) -> Tuple[int, ...]: - """Where each plane stands once ``tick`` ticks have played, in the order the block writes them. + """Where each plane stands once ``tick`` ticks have played, in block order. Every plane reads one value a tick but a bend plane, which reads one on each tick its channel flags, so a song returning to a tick re-enters each plane at a position of its own. @@ -104,16 +81,11 @@ def positions(self, tick: int) -> Tuple[int, ...]: Returns: Tuple[int, ...]: One position per plane. """ - return ( - tick, - tick, - self.pulse1.bend_position(tick), - tick, - tick, - self.pulse2.bend_position(tick), - tick, - tick, - self.triangle.bend_position(tick), - tick, - tick, + return tuple( + ( + flagged_ticks(self.planes[plane_index(plane.channel, PlaneRole.VALUE)][:tick]) + if plane.spans_flagged_ticks + else tick + ) + for plane in PLANES ) diff --git a/src/sampletones_player/compression/seeds.py b/src/sampletones_player/compression/seeds.py index 665a64f0f..8cd3c5715 100644 --- a/src/sampletones_player/compression/seeds.py +++ b/src/sampletones_player/compression/seeds.py @@ -51,7 +51,7 @@ def phrases_from_project( channel_registers(channel, played, timer_table), pitches, ) - phrases.extend(Phrase(body=plane[:MAX_PHRASE_LENGTH]) for plane in planes.ordered if _turns_over(plane)) + phrases.extend(Phrase(body=plane[:MAX_PHRASE_LENGTH]) for plane in planes if _turns_over(plane)) return tuple(phrases) diff --git a/src/sampletones_player/specification/compression.py b/src/sampletones_player/specification/compression.py index a8e301da5..1a71b9574 100644 --- a/src/sampletones_player/specification/compression.py +++ b/src/sampletones_player/specification/compression.py @@ -2,7 +2,6 @@ from math import ceil from typing import Final -from sampletones_core.constants.enums import TONE_CHANNELS, ChannelName from sampletones_player.specification.binary import ( BYTE_VALUES, MAX_BYTE_VALUE, @@ -52,9 +51,4 @@ class TokenTag(IntEnum): BEND_FLAG: Final[int] = 0x80 PITCH_INDEX_MASK: Final[int] = BEND_FLAG - 1 -PLANES_PER_CHANNEL: Final[int] = 2 -TONE_PLANES_PER_CHANNEL: Final[int] = PLANES_PER_CHANNEL + 1 -PLANE_COUNT: Final[int] = sum( - TONE_PLANES_PER_CHANNEL if channel in TONE_CHANNELS else PLANES_PER_CHANNEL for channel in ChannelName.items() -) PLANE_STATE_SIZE: Final[int] = 8 diff --git a/src/sampletones_player/specification/planes.py b/src/sampletones_player/specification/planes.py new file mode 100644 index 000000000..366990b08 --- /dev/null +++ b/src/sampletones_player/specification/planes.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +from enum import StrEnum +from typing import Final, Tuple + +from pydantic import BaseModel, ConfigDict + +from sampletones_core.constants.enums import ChannelName + + +class PlaneRole(StrEnum): + """The part of a channel's tick one plane carries. + + Attributes: + CONTROL: How the channel sounds — its timbre, and its volume where it has one. + VALUE: What the channel sounds — a pitch index, or the noise channel's period byte. + BEND: How far a tick stands from the divider the pitch it names sounds at. + """ + + CONTROL = "control" + VALUE = "value" + BEND = "bend" + + +class Plane(BaseModel): + """One byte series a song block writes, named by the channel and the part it carries. + + Attributes: + channel: The channel the plane belongs to. + role: The part of the channel's tick the plane carries. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + channel: ChannelName + role: PlaneRole + + @property + def name(self) -> str: + """The name the song block writes the plane under.""" + return f"{self.channel.value}_{self.role.value}" + + @property + def spans_flagged_ticks(self) -> bool: + """Whether the plane holds a value per flagged tick rather than one per tick.""" + return self.role is PlaneRole.BEND + + +PLANES: Final[Tuple[Plane, ...]] = ( + Plane(channel=ChannelName.PULSE1, role=PlaneRole.CONTROL), + Plane(channel=ChannelName.PULSE1, role=PlaneRole.VALUE), + Plane(channel=ChannelName.PULSE1, role=PlaneRole.BEND), + Plane(channel=ChannelName.PULSE2, role=PlaneRole.CONTROL), + Plane(channel=ChannelName.PULSE2, role=PlaneRole.VALUE), + Plane(channel=ChannelName.PULSE2, role=PlaneRole.BEND), + Plane(channel=ChannelName.TRIANGLE, role=PlaneRole.CONTROL), + Plane(channel=ChannelName.TRIANGLE, role=PlaneRole.VALUE), + Plane(channel=ChannelName.TRIANGLE, role=PlaneRole.BEND), + Plane(channel=ChannelName.NOISE, role=PlaneRole.CONTROL), + Plane(channel=ChannelName.NOISE, role=PlaneRole.VALUE), +) +PLANE_COUNT: Final[int] = len(PLANES) +PLANE_NAMES: Final[Tuple[str, ...]] = tuple(plane.name for plane in PLANES) + + +def channel_indices(channel: ChannelName) -> Tuple[int, ...]: + """Where ``channel``'s own planes stand in the song block, in the order it writes them. + + Args: + channel: The channel to read. + + Returns: + Tuple[int, ...]: The positions that channel's planes take. + """ + return tuple(index for index, plane in enumerate(PLANES) if plane.channel is channel) + + +def plane_index( + channel: ChannelName, + role: PlaneRole, +) -> int: + """Where the plane ``channel`` writes for ``role`` stands in the song block. + + Args: + channel: The channel the plane belongs to. + role: The part of the channel's tick the plane carries. + + Returns: + int: The position the plane takes. + + Raises: + ValueError: If the channel writes no plane for that role. + """ + for index, plane in enumerate(PLANES): + if plane.channel is channel and plane.role is role: + return index + + raise ValueError(f"the {channel.value} channel writes no {role.value} plane") diff --git a/src/sampletones_player/specification/song.py b/src/sampletones_player/specification/song.py index 5a81ecbc5..f6e03ad09 100644 --- a/src/sampletones_player/specification/song.py +++ b/src/sampletones_player/specification/song.py @@ -1,7 +1,7 @@ from typing import Final from sampletones_player.specification.binary import WORD_SIZE -from sampletones_player.specification.compression import PLANE_COUNT +from sampletones_player.specification.planes import PLANE_COUNT STEP_WHOLE_OFFSET: Final[int] = 0 STEP_FRACTION_OFFSET: Final[int] = STEP_WHOLE_OFFSET + 1 diff --git a/src/sampletones_tools/codec/report/encoding.py b/src/sampletones_tools/codec/report/encoding.py index 073ea8c7f..26597f7ac 100644 --- a/src/sampletones_tools/codec/report/encoding.py +++ b/src/sampletones_tools/codec/report/encoding.py @@ -2,6 +2,7 @@ from time import process_time from typing import Final, List, Sequence, Tuple +from sampletones_core.constants.enums import PULSE_CHANNELS from sampletones_player.compression.absent import is_absent from sampletones_player.compression.compressed import CompressedPlanes from sampletones_player.compression.dictionary.table import phrase_table @@ -11,10 +12,10 @@ from sampletones_player.compression.matches.matcher import PhraseMatcher from sampletones_player.compression.options import CodecOptions from sampletones_player.compression.parse.plane import parse_plane -from sampletones_player.compression.planes.channel import TonePlanes from sampletones_player.compression.planes.song import SongPlanes from sampletones_player.compression.scheme import CompressionScheme from sampletones_player.registers.streams import ChannelStreams +from sampletones_player.specification.planes import PLANES, PlaneRole from sampletones_player.specification.registers import DUTY_CYCLE_SHIFT from sampletones_tools.codec.report.corpus import CorpusEntry from sampletones_tools.codec.report.rows import ReportRow @@ -213,17 +214,12 @@ def _register_planes(streams: ChannelStreams) -> Tuple[bytes, ...]: def _split_control_planes(planes: SongPlanes) -> Tuple[bytes, ...]: """Every plane with each pulse channel's control split into duty and volume.""" split: List[bytes] = [] - for channels in planes.ordered: - if channels in (planes.pulse1, planes.pulse2): - split.append(bytes(control >> DUTY_CYCLE_SHIFT for control in channels.control)) - split.append(bytes(control & CONTROL_LEVEL_MASK for control in channels.control)) + for plane, played in zip(PLANES, planes.planes, strict=True): + if plane.role is PlaneRole.CONTROL and plane.channel in PULSE_CHANNELS: + split.append(bytes(control >> DUTY_CYCLE_SHIFT for control in played)) + split.append(bytes(control & CONTROL_LEVEL_MASK for control in played)) else: - split.append(channels.control) - - split.append(channels.value) - match channels: - case TonePlanes(): - split.append(channels.bend) + split.append(played) return tuple(split) diff --git a/src/sampletones_tools/codec/report/session.py b/src/sampletones_tools/codec/report/session.py index 5a8d66019..c846e428c 100644 --- a/src/sampletones_tools/codec/report/session.py +++ b/src/sampletones_tools/codec/report/session.py @@ -3,7 +3,8 @@ from typing import Final, Sequence, Tuple from sampletones_player.driver.image import DriverImage -from sampletones_player.specification.compression import PLANE_COUNT, PLANE_STATE_SIZE +from sampletones_player.specification.compression import PLANE_STATE_SIZE +from sampletones_player.specification.planes import PLANE_COUNT from sampletones_tools.codec.report.corpus import CorpusEntry, corpus_entries from sampletones_tools.codec.report.encoding import Encoding, encode_corpus, report_rows from sampletones_tools.codec.report.rows import report_table, write_markdown diff --git a/src/sampletones_tools/codec/study/accounting/fixed.py b/src/sampletones_tools/codec/study/accounting/fixed.py index c168e59b5..ee7965e79 100644 --- a/src/sampletones_tools/codec/study/accounting/fixed.py +++ b/src/sampletones_tools/codec/study/accounting/fixed.py @@ -6,8 +6,9 @@ from sampletones_player.specification.binary import WORD_SIZE from sampletones_player.specification.compression import ( PHRASE_TABLE_ENTRY_SIZE, - PLANE_COUNT, ) +from sampletones_core.constants.enums import TONE_CHANNELS +from sampletones_player.specification.planes import PLANE_COUNT, PLANES, PlaneRole from sampletones_player.specification.song import SONG_HEADER_SIZE from sampletones_tools.codec.study.accounting.finding import Finding from sampletones_tools.codec.study.corpus.song import StudySong @@ -54,8 +55,12 @@ def fixed_overheads( Returns: FixedOverheads: The fixed bytes, part by part. """ - tones = (song.planes.pulse1, song.planes.pulse2, song.planes.triangle) - highest = max(pitch_index(value) for channel in tones for value in channel.value) + tones = ( + song.planes.planes[index] + for index, plane in enumerate(PLANES) + if plane.role is PlaneRole.VALUE and plane.channel in TONE_CHANNELS + ) + highest = max(pitch_index(value) for plane in tones for value in plane) return FixedOverheads( header=SONG_HEADER_SIZE, pitch_table=len(song.pitches.data), diff --git a/src/sampletones_tools/codec/study/corpus/song.py b/src/sampletones_tools/codec/study/corpus/song.py index ad8d5c6a0..3f12b9c31 100644 --- a/src/sampletones_tools/codec/study/corpus/song.py +++ b/src/sampletones_tools/codec/study/corpus/song.py @@ -6,7 +6,6 @@ from sampletones_core.constants.enums import ChannelName from sampletones_player.compression.dictionary.phrase import Phrase from sampletones_player.compression.pitch import PitchTable -from sampletones_player.compression.planes.channel import ChannelPlanes from sampletones_player.compression.planes.song import SongPlanes @@ -31,12 +30,12 @@ class StudySlice: Attributes: channel: The channel the slice plays on. - planes: The slice's planes as the production codec separates them. + planes: The slice's planes as the production codec separates them, in block order. notes: The note each of its ticks names, empty on the noise channel. """ channel: ChannelName - planes: ChannelPlanes + planes: Tuple[bytes, ...] notes: bytes diff --git a/src/sampletones_tools/codec/study/layouts/planes.py b/src/sampletones_tools/codec/study/layouts/planes.py index d9449fee0..01a72e011 100644 --- a/src/sampletones_tools/codec/study/layouts/planes.py +++ b/src/sampletones_tools/codec/study/layouts/planes.py @@ -1,8 +1,8 @@ from typing import List, Sequence, Tuple +from sampletones_core.constants.enums import TONE_CHANNELS, ChannelName from sampletones_player.compression.dictionary.phrase import Phrase from sampletones_player.compression.pitch import PitchTable -from sampletones_player.compression.planes.channel import TonePlanes from sampletones_player.specification.compression import MAX_PHRASE_LENGTH from sampletones_tools.codec.study.corpus.song import StudySlice, StudySong from sampletones_tools.codec.study.layouts.layout import PlaneLayout @@ -22,12 +22,12 @@ def layout_planes( Returns: Tuple[bytes, ...]: The planes; a flagged bend plane covers fewer values than the song's ticks. """ - tones = (song.planes.pulse1, song.planes.pulse2, song.planes.triangle) + tones = tuple(song.planes.of(channel) for channel in ChannelName.items() if channel in TONE_CHANNELS) planes: List[bytes] = [] for channel, notes in zip(tones, song.notes, strict=True): planes.extend(tone_planes(channel, notes, song.pitches, layout)) - planes.extend(song.planes.noise.ordered) + planes.extend(song.planes.of(ChannelName.NOISE)) return tuple(planes) @@ -51,11 +51,11 @@ def layout_seeds( """ phrases: List[Phrase] = [] for study_slice in slices: - match study_slice.planes: - case TonePlanes(): - planes: Tuple[bytes, ...] = tone_planes(study_slice.planes, study_slice.notes, pitches, layout) - case _: - planes = study_slice.planes.ordered + planes = ( + tone_planes(study_slice.planes, study_slice.notes, pitches, layout) + if study_slice.channel in TONE_CHANNELS + else study_slice.planes + ) phrases.extend(Phrase(body=plane[:MAX_PHRASE_LENGTH]) for plane in planes if len(set(plane)) > 1) diff --git a/src/sampletones_tools/codec/study/layouts/tone.py b/src/sampletones_tools/codec/study/layouts/tone.py index 1e2b1333b..6b05c20d2 100644 --- a/src/sampletones_tools/codec/study/layouts/tone.py +++ b/src/sampletones_tools/codec/study/layouts/tone.py @@ -2,7 +2,6 @@ from sampletones_core.timers.nearest import NearestPitch from sampletones_player.compression.pitch import PitchTable -from sampletones_player.compression.planes.channel import TonePlanes from sampletones_player.compression.planes.flags import flagged_value, note_flags from sampletones_player.compression.planes.rebuild import tone_dividers from sampletones_player.registers.dividers import anchored_pitches @@ -74,7 +73,7 @@ def bend_flags( def tone_planes( - planes: TonePlanes, + planes: Tuple[bytes, ...], notes: bytes, pitches: PitchTable, layout: PlaneLayout, @@ -94,11 +93,12 @@ def tone_planes( Raises: ValueError: If a flagged layout meets an index reaching the flag's bit. """ - anchored = layout_anchors(tone_dividers(planes, pitches.timers), notes, pitches, layout.anchor) + control, named, offsets = planes + anchored = layout_anchors(tone_dividers(named, offsets, pitches.timers), notes, pitches, layout.anchor) flags = bend_flags(anchored, layout.form) bend = bytes(unsigned_byte(pitch.offset) for pitch, flag in zip(anchored, flags) if flag) if layout.form is BendForm.DENSE: - return planes.control, bytes(pitch.pitch for pitch in anchored), bend + return control, bytes(pitch.pitch for pitch in anchored), bend value = bytes(flagged_value(pitch.pitch, flag) for pitch, flag in zip(anchored, flags)) - return planes.control, value, bend + return control, value, bend diff --git a/tests/integration/nsf/test_driver_bend.py b/tests/integration/nsf/test_driver_bend.py index 44cd0b2b0..135a6e1bc 100644 --- a/tests/integration/nsf/test_driver_bend.py +++ b/tests/integration/nsf/test_driver_bend.py @@ -9,6 +9,7 @@ from sampletones_core.timers.arithmetic import bent_timer from sampletones_player.builder import song_from_reconstruction, streams_from_instructions from sampletones_player.compression.decode import decode_planes +from sampletones_player.specification.planes import PlaneRole, plane_index from sampletones_player.compression.scheme import CompressionScheme from sampletones_player.song import Song from sampletones_player.specification.binary import BYTE_VALUES @@ -220,6 +221,9 @@ def test_every_tick_sounds_the_divider_the_sequencer_renders(self, test_case: Te assert sounded_dividers(song)[: len(expected)] == expected +PULSE1_BEND: Final[int] = plane_index(ChannelName.PULSE1, PlaneRole.BEND) + + class TestABentSongComingRound: """A song returning to a tick re-enters each bend plane past the flags played before it.""" @@ -238,8 +242,8 @@ def repeating(self) -> Song: return player_song(streams, NTSC_RATE, loop_tick=self.LOOP_TICK) def test_the_loop_tick_falls_after_flagged_ticks(self, repeating: Song) -> None: - pulse1 = decode_planes(repeating.planes).pulse1 - assert pulse1.bend_position(self.LOOP_TICK) > 0 + planes = decode_planes(repeating.planes) + assert planes.positions(self.LOOP_TICK)[PULSE1_BEND] > 0 def test_the_console_writes_what_the_model_states_across_its_loops(self, repeating: Song) -> None: calls = play_calls_reaching(repeating, self.ROUNDS * repeating.ticks) diff --git a/tests/integration/nsf/test_nsf_pipeline.py b/tests/integration/nsf/test_nsf_pipeline.py index 6962b0d16..22c7e00e8 100644 --- a/tests/integration/nsf/test_nsf_pipeline.py +++ b/tests/integration/nsf/test_nsf_pipeline.py @@ -14,12 +14,12 @@ from sampletones_player.nsf.song import song_to_bytes from sampletones_player.song import Song from sampletones_player.specification.binary import WORD_SIZE -from sampletones_player.specification.compression import PLANE_COUNT from sampletones_player.specification.nsf import ( HEADER_SIZE, NSF_MAGIC, PROGRAM_SIZE, ) +from sampletones_player.specification.planes import PLANE_COUNT from sampletones_player.specification.song import ( ABSENT_STREAM, LOOP_TICK_OFFSET, diff --git a/tests/suite/player.py b/tests/suite/player.py index 1e9b997aa..f5e5831c3 100644 --- a/tests/suite/player.py +++ b/tests/suite/player.py @@ -22,7 +22,6 @@ from sampletones_player.compression.encode import emit, encode_planes from sampletones_player.compression.options import EVERY_LAYER from sampletones_player.compression.pitch import PITCH_COUNT, PitchTable -from sampletones_player.compression.planes.channel import TonePlanes from sampletones_player.compression.planes.flags import flagged_value from sampletones_player.compression.planes.order import PlaneOrder from sampletones_player.compression.planes.separate import planes_from_streams @@ -37,7 +36,12 @@ from sampletones_player.specification.binary import unsigned_byte from sampletones_player.specification.compression import ( MAX_LITERAL_BYTES, +) +from sampletones_player.specification.planes import ( PLANE_COUNT, + PLANES, + PlaneRole, + plane_index, ) from sampletones_player.specification.registers import ( DUTY_CYCLE_SHIFT, @@ -170,14 +174,10 @@ def bent_song( sounding = pulse_tick(PLAYER_FULL_VOLUME, 0, PLAYER_PITCHES.timers[pitch_index]) planes = planes_from_streams(resting_streams((sounding,) * len(bends)), PLAYER_PITCHES) bent = SongPlanes( - pulse1=TonePlanes( - control=planes.pulse1.control, - value=bytes(flagged_value(pitch_index, bend != 0) for bend in bends), - bend=bytes(unsigned_byte(bend) for bend in bends if bend), - ), - pulse2=planes.pulse2, - triangle=planes.triangle, - noise=planes.noise, + planes=planes.planes._replace( + pulse1_value=bytes(flagged_value(pitch_index, bend != 0) for bend in bends), + pulse1_bend=bytes(unsigned_byte(bend) for bend in bends if bend), + ) ) return Song( planes=encode_planes(bent, (), options=EVERY_LAYER, boundaries=frozenset()), @@ -210,6 +210,24 @@ def silent_pulse() -> PulseInstruction: PLAYER_VARIED_SEED: Final[int] = 7 +def sounding_planes( + control: bytes, + value: bytes, + bend: bytes, +) -> SongPlanes: + """A song's planes, the first pulse channel sounding what it is given and the rest resting.""" + resting = [b"" if plane.spans_flagged_ticks else bytes(len(control)) for plane in PLANES] + for role, played in zip((PlaneRole.CONTROL, PlaneRole.VALUE, PlaneRole.BEND), (control, value, bend)): + resting[plane_index(ChannelName.PULSE1, role)] = played + + return SongPlanes(planes=PlaneOrder.across(resting)) + + +def every_plane_spelling(stream: bytes) -> PlaneOrder: + """One stream standing for every plane the song block writes.""" + return PlaneOrder.across((stream,) * PLANE_COUNT) + + def spelled_song(ticks: int, nes_frequency: int) -> Song: """A song whose every plane spells its values out, which is the most room a song can take. @@ -227,7 +245,7 @@ def spelled_song(ticks: int, nes_frequency: int) -> Song: return Song( planes=CompressedPlanes( phrases=PhraseTable(phrases=()), - streams=PlaneOrder.across((stream,) * PLANE_COUNT), + streams=every_plane_spelling(stream), ticks=ticks, ), pitches=PLAYER_PITCHES, diff --git a/tests/suite/study.py b/tests/suite/study.py index ef2161faf..08dc0f3c0 100644 --- a/tests/suite/study.py +++ b/tests/suite/study.py @@ -1,7 +1,8 @@ from typing import Final, Tuple -from sampletones_player.compression.planes.channel import ChannelPlanes, TonePlanes +from sampletones_player.compression.planes.order import PlaneOrder from sampletones_player.compression.planes.song import SongPlanes +from sampletones_player.specification.planes import PLANES from sampletones_shared.constants.music import LIMIT_MIN_PITCH from sampletones_tools.codec.study.corpus.notes import TONE_ORDER from sampletones_tools.codec.study.corpus.song import StudySlice @@ -16,10 +17,4 @@ def lowest_notes(ticks: int) -> Tuple[bytes, ...]: def resting_planes(ticks: int) -> SongPlanes: """A song's planes holding zero throughout, every tone channel bending nowhere.""" - tone = TonePlanes(control=bytes(ticks), value=bytes(ticks), bend=b"") - return SongPlanes( - pulse1=tone, - pulse2=tone, - triangle=tone, - noise=ChannelPlanes(control=bytes(ticks), value=bytes(ticks)), - ) + return SongPlanes(planes=PlaneOrder.across(b"" if plane.spans_flagged_ticks else bytes(ticks) for plane in PLANES)) diff --git a/tests/unit/sampletones_player/compression/matches/test_matcher.py b/tests/unit/sampletones_player/compression/matches/test_matcher.py index f35ca0764..e5d70ba66 100644 --- a/tests/unit/sampletones_player/compression/matches/test_matcher.py +++ b/tests/unit/sampletones_player/compression/matches/test_matcher.py @@ -4,8 +4,7 @@ from sampletones_player.compression.dictionary.table import phrase_table from sampletones_player.compression.matches.cache import KEY_LENGTH, MatchCache from sampletones_player.compression.matches.index import PlaneIndex -from sampletones_player.compression.matches.match import PhraseMatch -from sampletones_player.compression.matches.matcher import PhraseMatcher +from sampletones_player.compression.matches.matcher import PhraseMatch, PhraseMatcher from sampletones_player.specification.compression import MAX_PHRASE_TICKS MOTIF: Final[bytes] = bytes((40, 44, 47)) diff --git a/tests/unit/sampletones_player/compression/planes/test_channel.py b/tests/unit/sampletones_player/compression/planes/test_channel.py deleted file mode 100644 index 7db19099d..000000000 --- a/tests/unit/sampletones_player/compression/planes/test_channel.py +++ /dev/null @@ -1,21 +0,0 @@ -import pytest -from pydantic import ValidationError - -from sampletones_player.compression.planes.channel import ChannelPlanes - - -class TestAChannelWritesTwoPlanesOfEqualLength: - """The planes are read tick for tick, so a channel states them all across the same ticks.""" - - def test_both_planes_reach_the_ticks_the_channel_covers(self) -> None: - planes = ChannelPlanes(control=bytes(4), value=bytes(4)) - assert planes.ticks == 4 - assert planes.ordered == (planes.control, planes.value) - - def test_planes_covering_different_ticks_are_refused(self) -> None: - with pytest.raises(ValidationError): - ChannelPlanes(control=bytes(3), value=bytes(2)) - - def test_planes_covering_no_tick_are_refused(self) -> None: - with pytest.raises(ValidationError): - ChannelPlanes(control=b"", value=b"") diff --git a/tests/unit/sampletones_player/compression/planes/test_order.py b/tests/unit/sampletones_player/compression/planes/test_order.py index 571c9331c..c2a9583a9 100644 --- a/tests/unit/sampletones_player/compression/planes/test_order.py +++ b/tests/unit/sampletones_player/compression/planes/test_order.py @@ -1,7 +1,7 @@ import pytest from sampletones_player.compression.planes.order import PlaneOrder -from sampletones_player.specification.compression import PLANE_COUNT +from sampletones_player.specification.planes import PLANE_COUNT def numbered(count: int) -> PlaneOrder: diff --git a/tests/unit/sampletones_player/compression/planes/test_separate.py b/tests/unit/sampletones_player/compression/planes/test_separate.py index 5c0ba9fbf..e167dd2fa 100644 --- a/tests/unit/sampletones_player/compression/planes/test_separate.py +++ b/tests/unit/sampletones_player/compression/planes/test_separate.py @@ -41,7 +41,7 @@ def test_a_tone_channel_names_its_pitch_rather_than_its_divider( pitches: PitchTable, ) -> None: planes = planes_from_streams(sounding_streams, pitches) - assert planes.pulse1.value == bytes((LOW_INDEX, HIGH_INDEX, HIGH_INDEX)) + assert planes.planes.pulse1_value == bytes((LOW_INDEX, HIGH_INDEX, HIGH_INDEX)) def test_the_noise_channel_names_the_period_its_register_takes( self, @@ -49,7 +49,7 @@ def test_the_noise_channel_names_the_period_its_register_takes( pitches: PitchTable, ) -> None: planes = planes_from_streams(sounding_streams, pitches) - assert planes.noise.value == bytes((NOISE_PERIOD,)) * planes.ticks + assert planes.planes.noise_value == bytes((NOISE_PERIOD,)) * planes.ticks def test_a_channel_running_out_early_holds_its_values_through_the_song( self, @@ -58,7 +58,7 @@ def test_a_channel_running_out_early_holds_its_values_through_the_song( ) -> None: planes = planes_from_streams(sounding_streams, pitches) assert planes.ticks == SOUNDING_TICKS - assert planes.pulse2.control == bytes((planes.pulse2.control[0],)) * SOUNDING_TICKS + assert planes.planes.pulse2_control == bytes((planes.planes.pulse2_control[0],)) * SOUNDING_TICKS def test_one_channel_separates_the_same_way_the_song_does( self, @@ -67,7 +67,7 @@ def test_one_channel_separates_the_same_way_the_song_does( ) -> None: planes = planes_from_streams(sounding_streams, pitches) pulse1 = channel_planes(ChannelName.PULSE1, sounding_streams.padded[0], pitches) - assert pulse1 == planes.pulse1 + assert pulse1 == planes.of(ChannelName.PULSE1) class TestABentTickSplitsIntoANoteAndABend: @@ -79,21 +79,21 @@ def test_an_unbent_tick_holds_a_bend_of_nothing( pitches: PitchTable, ) -> None: planes = planes_from_streams(sounding_streams, pitches) - assert planes.pulse1.bend == b"" + assert planes.planes.pulse1_bend == b"" def test_a_bent_tick_names_its_anchor_and_the_steps_from_it(self, pitches: PitchTable) -> None: bends = (2, -3, PAST_HALFWAY) streams = resting_streams([anchored_tick(pitches, LOW_INDEX, bend) for bend in bends]) planes = planes_from_streams(streams, pitches) - assert planes.pulse1.value == bytes(flagged_value(LOW_INDEX, True) for _ in bends) - assert planes.pulse1.bend == bytes(unsigned_byte(bend) for bend in bends) + assert planes.planes.pulse1_value == bytes(flagged_value(LOW_INDEX, True) for _ in bends) + assert planes.planes.pulse1_bend == bytes(unsigned_byte(bend) for bend in bends) def test_a_note_is_flagged_from_its_first_bend_to_its_last(self, pitches: PitchTable) -> None: bends = (0, 3, 0, -3, 0) streams = resting_streams([anchored_tick(pitches, LOW_INDEX, bend) for bend in bends]) planes = planes_from_streams(streams, pitches) - assert planes.pulse1.value == bytes(flagged_value(LOW_INDEX, 0 < tick < 4) for tick in range(len(bends))) - assert planes.pulse1.bend == bytes(unsigned_byte(bend) for bend in bends[1:4]) + assert planes.planes.pulse1_value == bytes(flagged_value(LOW_INDEX, 0 < tick < 4) for tick in range(len(bends))) + assert planes.planes.pulse1_bend == bytes(unsigned_byte(bend) for bend in bends[1:4]) def test_a_divider_past_the_byte_from_its_anchor_is_refused(self, pitches: PitchTable) -> None: streams = resting_streams((anchored_tick(pitches, LOW_INDEX, -SIGNED_BYTE_LIMIT - 1),)) diff --git a/tests/unit/sampletones_player/compression/planes/test_song.py b/tests/unit/sampletones_player/compression/planes/test_song.py index cece618c4..6c8b8f9cc 100644 --- a/tests/unit/sampletones_player/compression/planes/test_song.py +++ b/tests/unit/sampletones_player/compression/planes/test_song.py @@ -3,52 +3,50 @@ import pytest from pydantic import ValidationError -from sampletones_player.compression.planes.channel import ChannelPlanes, TonePlanes +from sampletones_core.constants.enums import ChannelName from sampletones_player.compression.planes.flags import flagged_value from sampletones_player.compression.planes.order import PlaneOrder from sampletones_player.compression.planes.song import SongPlanes -from sampletones_player.specification.compression import PLANE_COUNT +from sampletones_player.specification.planes import ( + PLANE_COUNT, + PLANES, + PlaneRole, + plane_index, +) +from tests.suite.player import sounding_planes BEND: Final[bytes] = bytes((0x00, 0xFD)) FLAGGED: Final[bytes] = bytes((flagged_value(3, True), flagged_value(4, True))) -PULSE1_BEND: Final[int] = 2 -BENDS: Final[Tuple[str, ...]] = ("pulse1_bend", "pulse2_bend", "triangle_bend") +PULSE1_BEND: Final[int] = plane_index(ChannelName.PULSE1, PlaneRole.BEND) +BENDS: Final[Tuple[str, ...]] = tuple(plane.name for plane in PLANES if plane.spans_flagged_ticks) -def song(control: bytes, value: bytes, bend: bytes) -> SongPlanes: - channel = TonePlanes(control=control, value=value, bend=bend) - resting = TonePlanes(control=bytes(len(control)), value=bytes(len(value)), bend=b"") - silent = ChannelPlanes(control=bytes(len(control)), value=bytes(len(value))) - return SongPlanes(pulse1=channel, pulse2=resting, triangle=resting, noise=silent) - - -class TestASongGathersItsChannelsPlanes: +class TestASongGathersEveryPlaneItsBlockWrites: """Every plane advances beside the rest, so the song states them all under one length.""" def test_a_song_carries_the_planes_a_block_writes(self) -> None: - assert len(song(bytes((1, 2)), FLAGGED, BEND).planes) == PLANE_COUNT + assert len(sounding_planes(bytes((1, 2)), FLAGGED, BEND).planes) == PLANE_COUNT - def test_the_planes_read_back_under_the_channels_that_write_them(self) -> None: - planes = song(bytes((1, 2)), FLAGGED, BEND) - assert SongPlanes.from_order(planes.planes) == planes + def test_a_plane_is_reached_by_the_name_the_block_writes_it_under(self) -> None: + planes = sounding_planes(bytes((1, 2)), FLAGGED, BEND).planes - def test_a_tone_channels_bend_reaches_the_block_beside_its_pitch(self) -> None: - planes = song(bytes((1, 2)), FLAGGED, BEND).planes assert planes.pulse1_bend == BEND - def test_a_song_lasts_the_ticks_its_channels_cover(self) -> None: - assert song(bytes((1, 2)), FLAGGED, BEND).ticks == 2 + def test_a_channel_answers_with_the_planes_it_writes(self) -> None: + planes = sounding_planes(bytes((1, 2)), FLAGGED, BEND) + + assert planes.of(ChannelName.PULSE1) == (bytes((1, 2)), FLAGGED, BEND) + + def test_a_song_lasts_the_ticks_its_planes_cover(self) -> None: + assert sounding_planes(bytes((1, 2)), FLAGGED, BEND).ticks == 2 - def test_channels_covering_different_ticks_are_refused(self) -> None: - short = TonePlanes(control=bytes(1), value=bytes(1), bend=b"") - long = TonePlanes(control=bytes(2), value=bytes(2), bend=b"") + def test_planes_covering_different_ticks_are_refused(self) -> None: with pytest.raises(ValidationError): - SongPlanes( - pulse1=short, - pulse2=long, - triangle=short, - noise=ChannelPlanes(control=bytes(1), value=bytes(1)), - ) + SongPlanes(planes=PlaneOrder.across(bytes(plane) for plane in range(1, PLANE_COUNT + 1))) + + def test_planes_covering_no_tick_are_refused(self) -> None: + with pytest.raises(ValidationError): + SongPlanes(planes=PlaneOrder.across(b"" for _ in range(PLANE_COUNT))) class TestABendPlaneCoversTheFlaggedTicks: @@ -56,19 +54,21 @@ class TestABendPlaneCoversTheFlaggedTicks: def test_a_bend_for_every_flagged_tick_is_taken(self) -> None: value = bytes((flagged_value(3, False), flagged_value(3, True))) - assert TonePlanes(control=bytes(2), value=value, bend=BEND[:1]).bend == BEND[:1] + + assert sounding_planes(bytes(2), value, BEND[:1]).planes.pulse1_bend == BEND[:1] def test_a_bend_plane_holding_more_than_the_flags_ask_for_is_refused(self) -> None: with pytest.raises(ValidationError): - TonePlanes(control=bytes(2), value=bytes((3, 4)), bend=BEND) + sounding_planes(bytes(2), bytes((3, 4)), BEND) def test_a_bend_plane_holding_fewer_than_the_flags_ask_for_is_refused(self) -> None: with pytest.raises(ValidationError): - TonePlanes(control=bytes(2), value=FLAGGED, bend=BEND[:1]) + sounding_planes(bytes(2), FLAGGED, BEND[:1]) def test_a_song_returning_to_a_tick_re_enters_the_bend_plane_past_the_flags_before_it(self) -> None: value = bytes((flagged_value(3, True), flagged_value(3, False), flagged_value(3, True))) - planes = song(bytes(3), value, BEND) + planes = sounding_planes(bytes(3), value, BEND) + assert [planes.positions(tick)[PULSE1_BEND] for tick in range(4)] == [0, 1, 1, 2] dense = [position for name, position in zip(PlaneOrder.names(), planes.positions(2)) if name not in BENDS] assert dense == [2] * len(dense) diff --git a/tests/unit/sampletones_player/compression/test_encode.py b/tests/unit/sampletones_player/compression/test_encode.py index c26c2bded..ecd05679a 100644 --- a/tests/unit/sampletones_player/compression/test_encode.py +++ b/tests/unit/sampletones_player/compression/test_encode.py @@ -6,7 +6,6 @@ from sampletones_player.compression.dictionary.phrase import Phrase from sampletones_player.compression.encode import emit, encode_planes from sampletones_player.compression.options import CodecOptions -from sampletones_player.compression.planes.channel import ChannelPlanes, TonePlanes from sampletones_player.compression.planes.song import SongPlanes from sampletones_player.compression.progress.report import CodecProgress from sampletones_player.compression.tokens.hold import HoldToken @@ -18,6 +17,7 @@ TokenTag, ) from sampletones_shared.exceptions import OperationCanceled +from tests.suite.player import sounding_planes from tests.suite.progress import FIRST_REPORT, RecordingReporter EVERY_LAYER: Final[CodecOptions] = CodecOptions( @@ -39,11 +39,7 @@ def song_planes(control: bytes, value: bytes) -> SongPlanes: - unbent = bytes(len(control)) - channel = TonePlanes(control=control, value=value, bend=b"") - resting = TonePlanes(control=unbent, value=bytes(len(value)), bend=b"") - silent = ChannelPlanes(control=unbent, value=bytes(len(value))) - return SongPlanes(pulse1=channel, pulse2=resting, triangle=resting, noise=silent) + return sounding_planes(control, value, b"") class TestWhatATokenLooksLikeOnTheBus: diff --git a/tests/unit/sampletones_player/compression/test_seeds.py b/tests/unit/sampletones_player/compression/test_seeds.py index 1ff792f1a..7920316db 100644 --- a/tests/unit/sampletones_player/compression/test_seeds.py +++ b/tests/unit/sampletones_player/compression/test_seeds.py @@ -9,7 +9,6 @@ from sampletones_core.project.voices.instrument import Instrument from sampletones_core.timers.utils import get_timer_table from sampletones_player.compression.pitch import PitchTable -from sampletones_player.compression.planes.channel import ChannelPlanes from sampletones_player.compression.planes.separate import channel_planes from sampletones_player.compression.seeds import phrases_from_project from sampletones_player.registers.channel import channel_registers @@ -33,7 +32,7 @@ def project() -> Project: @pytest.fixture -def slice_planes(project: Project) -> ChannelPlanes: +def slice_planes(project: Project) -> Tuple[bytes, ...]: """The planes the project's own slice writes on the channel it plays.""" instructions = project.voices[0].reconstruction.get_channel_instructions(ChannelName.PULSE1) registers = channel_registers( @@ -54,18 +53,18 @@ class TestTheInstrumentsSeedTheDictionary: def test_the_phrases_are_the_planes_the_slice_turns_over( self, project: Project, - slice_planes: ChannelPlanes, + slice_planes: Tuple[bytes, ...], ) -> None: - turning = tuple(plane for plane in slice_planes.ordered if len(set(plane)) > 1) + turning = tuple(plane for plane in slice_planes if len(set(plane)) > 1) assert _offered(project) == turning def test_a_plane_holding_one_value_offers_the_dictionary_nothing( self, project: Project, - slice_planes: ChannelPlanes, + slice_planes: Tuple[bytes, ...], ) -> None: """A hold covers such a plane more cheaply than any phrase naming it could.""" - held = tuple(plane for plane in slice_planes.ordered if len(set(plane)) == 1) + held = tuple(plane for plane in slice_planes if len(set(plane)) == 1) assert held assert not set(held) & set(_offered(project)) diff --git a/tests/unit/sampletones_player/nsf/test_layout.py b/tests/unit/sampletones_player/nsf/test_layout.py index bc0f010cb..808d0d129 100644 --- a/tests/unit/sampletones_player/nsf/test_layout.py +++ b/tests/unit/sampletones_player/nsf/test_layout.py @@ -7,8 +7,8 @@ PHRASE_LENGTH_SIZE, PHRASE_TABLE_COUNT_SIZE, PHRASE_TABLE_ENTRY_SIZE, - PLANE_COUNT, ) +from sampletones_player.specification.planes import PLANE_COUNT from sampletones_player.specification.song import ABSENT_STREAM, SONG_HEADER_SIZE from tests.suite.player import ( PLAYER_FULL_VOLUME, diff --git a/tests/unit/sampletones_player/nsf/test_song.py b/tests/unit/sampletones_player/nsf/test_song.py index 17d03d46e..6338fde18 100644 --- a/tests/unit/sampletones_player/nsf/test_song.py +++ b/tests/unit/sampletones_player/nsf/test_song.py @@ -10,7 +10,7 @@ from sampletones_player.nsf.song import song_to_bytes from sampletones_player.song import Song from sampletones_player.specification.binary import WORD_SIZE -from sampletones_player.specification.compression import PLANE_COUNT +from sampletones_player.specification.planes import PLANE_COUNT from sampletones_player.specification.song import ( ABSENT_STREAM, LOOP_ENTRIES_OFFSET, diff --git a/tests/unit/sampletones_player/specification/test_planes.py b/tests/unit/sampletones_player/specification/test_planes.py new file mode 100644 index 000000000..68eea092d --- /dev/null +++ b/tests/unit/sampletones_player/specification/test_planes.py @@ -0,0 +1,51 @@ +from typing import Final + +import pytest + +from sampletones_core.constants.enums import TONE_CHANNELS, ChannelName +from sampletones_player.compression.planes.order import PlaneOrder +from sampletones_player.specification.planes import ( + PLANE_COUNT, + PLANE_NAMES, + PLANES, + PlaneRole, + channel_indices, + plane_index, +) + +PLANES_A_CHANNEL_SOUNDS_WITH: Final[int] = 2 + + +class TestOneTableStatesEveryPlaneASongBlockWrites: + """The song block writes its planes in one order, and the table is where that order is stated.""" + + def test_every_channel_writes_what_it_sounds_and_how_it_sounds_it(self) -> None: + for channel in ChannelName.items(): + roles = {PLANES[index].role for index in channel_indices(channel)} + + assert {PlaneRole.CONTROL, PlaneRole.VALUE} <= roles + + def test_a_bend_belongs_to_each_channel_whose_divider_moves(self) -> None: + bending = {PLANES[index].channel for index, plane in enumerate(PLANES) if plane.spans_flagged_ticks} + + assert bending == TONE_CHANNELS + + def test_a_plane_is_named_by_the_channel_and_the_part_it_carries(self) -> None: + assert PLANES[plane_index(ChannelName.NOISE, PlaneRole.VALUE)].name == "noise_value" + + def test_the_table_names_each_plane_once(self) -> None: + assert len(set(PLANE_NAMES)) == PLANE_COUNT + + def test_a_channel_writing_no_plane_for_a_role_is_refused(self) -> None: + with pytest.raises(ValueError, match="writes no bend plane"): + plane_index(ChannelName.NOISE, PlaneRole.BEND) + + +class TestTheStreamsStandUnderTheNamesTheTableStates: + """A stream is reached by its own name, and the names are the table's — a test keeps them one.""" + + def test_the_streams_carry_the_names_the_table_states(self) -> None: + assert PlaneOrder._fields == PLANE_NAMES + + def test_the_noise_channel_reads_no_bend(self) -> None: + assert len(channel_indices(ChannelName.NOISE)) == PLANES_A_CHANNEL_SOUNDS_WITH diff --git a/tests/unit/sampletones_tools/codec/study/test_layouts.py b/tests/unit/sampletones_tools/codec/study/test_layouts.py index 44d4777d1..699bc3e72 100644 --- a/tests/unit/sampletones_tools/codec/study/test_layouts.py +++ b/tests/unit/sampletones_tools/codec/study/test_layouts.py @@ -12,7 +12,6 @@ from sampletones_player.builder import streams_from_instructions from sampletones_player.compression.absent import is_absent from sampletones_player.compression.pitch import PitchTable -from sampletones_player.compression.planes.channel import TonePlanes from sampletones_player.compression.planes.separate import channel_planes, planes_from_streams from sampletones_player.compression.seeds import phrases_from_project from sampletones_player.registers.channel import channel_registers @@ -56,11 +55,12 @@ def coarse_frame(pitch: int, coarse: int) -> List[InstructionUnion]: return [PulseInstruction(on=True, pitch=pitch, volume=PLAYER_FULL_VOLUME, duty_cycle=0, coarse_detune=coarse)] -def pulse_planes(instructions: Sequence[InstructionUnion]) -> TonePlanes: +VALUE_PLANE: Final[int] = 1 + + +def pulse_planes(instructions: Sequence[InstructionUnion]) -> Tuple[bytes, ...]: registers = channel_registers(ChannelName.PULSE1, {ChannelName.PULSE1: instructions}, TIMER_TABLE) - planes = channel_planes(ChannelName.PULSE1, registers, PITCHES) - assert isinstance(planes, TonePlanes) - return planes + return channel_planes(ChannelName.PULSE1, registers, PITCHES) def written(instructions: Sequence[InstructionUnion], layout: PlaneLayout) -> Tuple[bytes, bytes, bytes]: @@ -115,7 +115,7 @@ def test_the_named_flagged_notes_layout_is_the_production_separation(self) -> No *coarse_frame(PLAYER_REFERENCE_PITCH, BEYOND_A_BYTE), ] planes = pulse_planes(instructions) - assert written(instructions, PRODUCTION) == planes.ordered + assert written(instructions, PRODUCTION) == planes def test_a_named_note_keeps_a_bend_past_halfway(self) -> None: instructions = bent_frames(PLAYER_REFERENCE_PITCH, (PAST_HALFWAY,)) @@ -163,7 +163,7 @@ def test_an_unbent_channel_holds_no_bend_and_no_flag(self, form: BendForm) -> No instructions = bent_frames(PLAYER_REFERENCE_PITCH, (0, 0, 0)) _, value, bend = written(instructions, PlaneLayout(Anchor.NEAREST, form)) assert bend == b"" - assert value == pulse_planes(instructions).value + assert value == pulse_planes(instructions)[VALUE_PLANE] class TestALayoutsSeeds: diff --git a/tests/unit/sampletones_tools/codec/study/test_sandbox.py b/tests/unit/sampletones_tools/codec/study/test_sandbox.py index 84341498a..f27d4f555 100644 --- a/tests/unit/sampletones_tools/codec/study/test_sandbox.py +++ b/tests/unit/sampletones_tools/codec/study/test_sandbox.py @@ -20,8 +20,8 @@ from sampletones_player.specification.compression import ( CHEAP_PHRASE_IDS, MAX_HOLD_TICKS, - PLANE_COUNT, ) +from sampletones_player.specification.planes import PLANE_COUNT from sampletones_shared.music import Tuning from sampletones_tools.codec.study.corpus.song import SongGroup, StudySong from sampletones_tools.codec.study.sandbox.context import PlaneContext @@ -95,7 +95,7 @@ def _song(ticks: int) -> StudySong: name="song", group=SongGroup.PROJECT, source=Path("song.stp"), - planes=SongPlanes.from_order(PlaneOrder.across(planes)), + planes=SongPlanes(planes=PlaneOrder.across(planes)), seeds=tuple(Phrase(body=body) for body in FIGURES), pitches=PitchTable.from_tuning(Tuning()), notes=lowest_notes(ticks), diff --git a/tests/unit/sampletones_tools/codec/study/test_variants.py b/tests/unit/sampletones_tools/codec/study/test_variants.py index 3bfdc193f..f6e71ddc1 100644 --- a/tests/unit/sampletones_tools/codec/study/test_variants.py +++ b/tests/unit/sampletones_tools/codec/study/test_variants.py @@ -13,7 +13,7 @@ from sampletones_player.compression.planes.song import SongPlanes from sampletones_player.compression.tokens.hold import HoldToken from sampletones_player.compression.tokens.literal import LiteralToken -from sampletones_player.specification.compression import PLANE_COUNT +from sampletones_player.specification.planes import PLANE_COUNT from sampletones_shared.music import Tuning from sampletones_tools.codec.study.corpus.song import SongGroup, StudySong from sampletones_tools.codec.study.measure import Measurement, production_encoding diff --git a/tests/unit/sampletones_tools/codec/study/test_verdicts.py b/tests/unit/sampletones_tools/codec/study/test_verdicts.py index d9c224d8a..6e2ccff2b 100644 --- a/tests/unit/sampletones_tools/codec/study/test_verdicts.py +++ b/tests/unit/sampletones_tools/codec/study/test_verdicts.py @@ -7,7 +7,7 @@ from sampletones_player.compression.pitch import PitchTable from sampletones_player.compression.planes.order import PlaneOrder from sampletones_player.compression.planes.song import SongPlanes -from sampletones_player.specification.compression import PLANE_COUNT +from sampletones_player.specification.planes import PLANE_COUNT from sampletones_player.specification.song import SONG_HEADER_SIZE from sampletones_shared.music import Tuning from sampletones_tools.codec.study.corpus.song import SongGroup, StudySong diff --git a/tests/unit/sampletones_tools/player/test_song_include.py b/tests/unit/sampletones_tools/player/test_song_include.py index 9d4cc268d..3e631e64e 100644 --- a/tests/unit/sampletones_tools/player/test_song_include.py +++ b/tests/unit/sampletones_tools/player/test_song_include.py @@ -15,12 +15,12 @@ PHRASE_TABLE_COUNT_SIZE, PHRASE_TABLE_ENTRY_SIZE, PITCH_INDEX_MASK, - PLANE_COUNT, PLANE_STATE_SIZE, TOKEN_OPERAND_MASK, TOKEN_TAG_MASK, TokenTag, ) +from sampletones_player.specification.planes import PLANE_COUNT from sampletones_player.specification.song import ( ABSENT_STREAM, LOOP_ENTRIES_OFFSET, From ea30ec5873953f9eebe74b5bf108b131e2b49b58 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 21 Sep 2026 13:21:46 +0200 Subject: [PATCH 04/11] Added: a repeat count in the bits a plane's register ignores --- src/sampletones_player/compression/absent.py | 16 - .../compression/compressed.py | 38 +-- src/sampletones_player/compression/decode.py | 35 +- src/sampletones_player/compression/encode.py | 37 ++- .../compression/planes}/symbols.py | 33 +- src/sampletones_player/compression/seeds.py | 15 +- .../driver/binary/driver.bin | Bin 710 -> 760 bytes src/sampletones_player/nsf/layout.py | 5 +- .../specification/compression.py | 2 +- .../specification/planes.py | 230 +++++++++++++- src/sampletones_tools/codec/absent.py | 16 + .../codec/report/encoding.py | 2 +- .../codec/study/accounting/fixed.py | 2 +- .../codec/study/layouts/encode.py | 13 +- .../codec/study/layouts/planes.py | 12 +- .../codec/study/packing/__init__.py | 0 .../codec/study/packing/coding.py | 35 -- .../codec/study/packing/encode.py | 115 ------- .../codec/study/packing/fitted.py | 74 ----- .../codec/study/packing/form.py | 152 --------- .../codec/study/packing/grammar.py | 101 ------ .../codec/study/packing/registers.py | 60 ---- .../codec/study/packing/scheme.py | 75 ----- .../codec/study/packing/table.py | 142 --------- .../codec/study/sandbox/reference.py | 13 +- .../codec/study/variants/packing.py | 96 ------ .../codec/study/variants/registry.py | 3 - .../player/assembly/include/song.inc | 13 +- .../player/assembly/source/channels.s | 37 ++- tests/integration/nsf/test_driver_bend.py | 2 +- tests/suite/player.py | 18 +- .../compression/planes/test_song.py | 23 +- .../compression/test_decode.py | 21 +- .../compression/test_seeds.py | 13 +- .../sampletones_player/nsf/test_layout.py | 2 +- .../unit/sampletones_player/nsf/test_song.py | 32 +- .../codec/study/test_layouts.py | 9 +- .../codec/study/test_packing.py | 300 ------------------ .../codec/study/test_sandbox.py | 13 +- .../codec/study/test_variants.py | 1 + .../player/test_song_include.py | 15 +- 41 files changed, 504 insertions(+), 1317 deletions(-) delete mode 100644 src/sampletones_player/compression/absent.py rename src/{sampletones_tools/codec/study/packing => sampletones_player/compression/planes}/symbols.py (77%) create mode 100644 src/sampletones_tools/codec/absent.py delete mode 100644 src/sampletones_tools/codec/study/packing/__init__.py delete mode 100644 src/sampletones_tools/codec/study/packing/coding.py delete mode 100644 src/sampletones_tools/codec/study/packing/encode.py delete mode 100644 src/sampletones_tools/codec/study/packing/fitted.py delete mode 100644 src/sampletones_tools/codec/study/packing/form.py delete mode 100644 src/sampletones_tools/codec/study/packing/grammar.py delete mode 100644 src/sampletones_tools/codec/study/packing/registers.py delete mode 100644 src/sampletones_tools/codec/study/packing/scheme.py delete mode 100644 src/sampletones_tools/codec/study/packing/table.py delete mode 100644 src/sampletones_tools/codec/study/variants/packing.py delete mode 100644 tests/unit/sampletones_tools/codec/study/test_packing.py diff --git a/src/sampletones_player/compression/absent.py b/src/sampletones_player/compression/absent.py deleted file mode 100644 index a333d2cb9..000000000 --- a/src/sampletones_player/compression/absent.py +++ /dev/null @@ -1,16 +0,0 @@ -from sampletones_player.specification.compression import INITIAL_PLANE_VALUE - - -def is_absent(plane: bytes) -> bool: - """Whether a plane plays only the value every plane starts at, so the block leaves it out. - - The driver starts every plane at that value and leaves an absent one standing there, which - is what makes a channel that never bends cost its bend plane nothing. - - Args: - plane: The values the plane plays. - - Returns: - bool: Whether the plane holds no other value. - """ - return set(plane) <= {INITIAL_PLANE_VALUE} diff --git a/src/sampletones_player/compression/compressed.py b/src/sampletones_player/compression/compressed.py index 74d4d9f31..82f47ede0 100644 --- a/src/sampletones_player/compression/compressed.py +++ b/src/sampletones_player/compression/compressed.py @@ -1,11 +1,10 @@ from __future__ import annotations -from typing import Optional, Sequence, Tuple +from typing import Optional, Tuple from pydantic import BaseModel, ConfigDict, model_validator from sampletones_player.compression.dictionary.table import PhraseTable -from sampletones_player.compression.entries import stream_entry from sampletones_player.compression.planes.order import PlaneOrder @@ -16,6 +15,8 @@ class CompressedPlanes(BaseModel): phrases: The dictionary every stream's tokens name. streams: The tokens each plane is written as, in the order the song block writes them. ticks: The ticks the song lasts, which is where each stream stops being read. + loop_entries: The byte each stream is re-entered at when the song comes round, counted + from that stream's own start, ``None`` for a plane holding no stream. """ model_config = ConfigDict(extra="forbid", frozen=True) @@ -23,6 +24,7 @@ class CompressedPlanes(BaseModel): phrases: PhraseTable streams: PlaneOrder ticks: int + loop_entries: Tuple[Optional[int], ...] @model_validator(mode="after") def _validate_the_song_lasts(self) -> CompressedPlanes: @@ -31,30 +33,16 @@ def _validate_the_song_lasts(self) -> CompressedPlanes: return self + @model_validator(mode="after") + def _validate_every_stream_states_where_it_is_re_entered(self) -> CompressedPlanes: + if len(self.loop_entries) != len(self.streams): + raise ValueError( + f"an entry stands for each of the {len(self.streams)} streams, " f"and {len(self.loop_entries)} stand" + ) + + return self + @property def size(self) -> int: """The bytes the dictionary and every plane's stream take together.""" return self.phrases.size + sum(len(stream) for stream in self.streams) - - def entries(self, positions: Sequence[int]) -> Tuple[Optional[int], ...]: - """The byte each stream is re-entered at, each plane standing at a position of its own. - - A song returning to a tick re-enters every plane where that tick leaves it, which for a - bend plane is the count of flagged ticks before it — see ``SongPlanes.positions``. - - Args: - positions: Where each plane stands once the song returns, in the order the song block - writes them. - - Returns: - Tuple[Optional[int], ...]: One byte offset per plane, each counted from its own - stream's start, in the order the song block writes them; ``None`` for an absent - plane, which holds no stream to re-enter. - - Raises: - ValueError: If a stream spans its position rather than starting a token there. - """ - return tuple( - stream_entry(stream, position) if stream else None - for stream, position in zip(self.streams, positions, strict=True) - ) diff --git a/src/sampletones_player/compression/decode.py b/src/sampletones_player/compression/decode.py index bd3d998b4..8f100da9d 100644 --- a/src/sampletones_player/compression/decode.py +++ b/src/sampletones_player/compression/decode.py @@ -5,9 +5,9 @@ from sampletones_player.compression.planes.flags import flagged_ticks from sampletones_player.compression.planes.order import PlaneOrder from sampletones_player.compression.planes.song import SongPlanes +from sampletones_player.compression.planes.symbols import unpack_plane from sampletones_player.specification.binary import BYTE_VALUES from sampletones_player.specification.compression import ( - INITIAL_PLANE_VALUE, PHRASE_ID_ESCAPE, TOKEN_OPERAND_MASK, TOKEN_TAG_MASK, @@ -15,6 +15,8 @@ ) from sampletones_player.specification.planes import ( PLANES, + SINGLE_TICK, + Plane, PlaneRole, plane_index, ) @@ -46,28 +48,38 @@ def _phrase_values( return played, position -def decode_plane(data: bytes, table: PhraseTable, ticks: int) -> bytes: +def decode_plane( + data: bytes, + table: PhraseTable, + plane: Plane, + ticks: int, +) -> bytes: """Plays a plane's token stream back into the values it writes, tick by tick. This is the reading the driver performs, stated where it is testable: every encoding is held - against it, so what the console plays and what the encoder meant are the same values. An - absent plane's empty stream plays the value every plane starts at throughout. + against it, so what the console plays and what the encoder meant are the same values. A + symbol covers the ticks its own count states, so the reading stops once the ticks the song + lasts are covered, wherever in a symbol that falls. An absent plane's empty stream plays the + value the driver seeds it to throughout. Args: data: The plane's token stream. table: The dictionary the tokens name. + plane: The plane the stream belongs to, for the byte it seeds to and how its byte divides. ticks: The ticks the song lasts. Returns: bytes: The values the plane writes, one per tick. """ + form = plane.form if not data: - return bytes((INITIAL_PLANE_VALUE,)) * ticks + return bytes((plane.seeded,)) * ticks - values = bytearray() - current = INITIAL_PLANE_VALUE + symbols = bytearray() + current = form.symbol(plane.seeded, SINGLE_TICK) + covered = 0 position = 0 - while len(values) < ticks: + while covered < ticks: opcode = data[position] position += 1 operand = opcode & TOKEN_OPERAND_MASK @@ -95,9 +107,10 @@ def decode_plane(data: bytes, table: PhraseTable, ticks: int) -> bytes: ) current = played[-1] - values.extend(played) + symbols.extend(played) + covered += sum(form.repeated(symbol) for symbol in played) - return bytes(values[:ticks]) + return unpack_plane(bytes(symbols), form)[:ticks] def decode_planes(compressed: CompressedPlanes) -> SongPlanes: @@ -119,6 +132,6 @@ def decode_planes(compressed: CompressedPlanes) -> SongPlanes: if plane.spans_flagged_ticks else compressed.ticks ) - played.append(decode_plane(stream, compressed.phrases, reach)) + played.append(decode_plane(stream, compressed.phrases, plane, reach)) return SongPlanes(planes=PlaneOrder.across(played)) diff --git a/src/sampletones_player/compression/encode.py b/src/sampletones_player/compression/encode.py index 589741739..f5639100a 100644 --- a/src/sampletones_player/compression/encode.py +++ b/src/sampletones_player/compression/encode.py @@ -1,13 +1,13 @@ from dataclasses import replace from typing import Dict, Final, FrozenSet, Iterable, Sequence, Tuple -from sampletones_player.compression.absent import is_absent from sampletones_player.compression.admit import admit_seeds from sampletones_player.compression.budget import DEFAULT_SEARCH_BUDGET, SearchBudget from sampletones_player.compression.compressed import CompressedPlanes from sampletones_player.compression.dictionary.phrase import Phrase from sampletones_player.compression.dictionary.prune import prune from sampletones_player.compression.dictionary.table import PhraseTable, phrase_table +from sampletones_player.compression.entries import stream_entry from sampletones_player.compression.matches.cache import MatchCache from sampletones_player.compression.matches.index import PlaneIndex from sampletones_player.compression.options import CodecOptions @@ -15,6 +15,7 @@ from sampletones_player.compression.parse.song import parse_planes from sampletones_player.compression.planes.order import PlaneOrder from sampletones_player.compression.planes.song import SongPlanes +from sampletones_player.compression.planes.symbols import pack_plane, symbol_boundaries from sampletones_player.compression.progress.monitor import CodecMonitor from sampletones_player.compression.progress.report import ( CodecReporter, @@ -25,6 +26,7 @@ from sampletones_player.compression.tokens.phrase import PhraseToken from sampletones_player.compression.tokens.types import TokenUnion from sampletones_player.specification.compression import PHRASE_ID_ESCAPE, TokenTag +from sampletones_player.specification.planes import PLANES from sampletones_shared.utils.progress import silent_reporter STREAM_START: Final[int] = 0 @@ -134,8 +136,8 @@ def encode_streams( phrases inside the opcodes that name them. Each plane is read against the shared dictionary on its own, so the planes may cover - different numbers of values. A plane playing only the value every plane starts at is absent: - it takes no stream, and the block states it with a sentinel the driver skips. + different numbers of values. A plane reaching the encoder empty is absent: it takes no + stream, and the block states it with a sentinel the driver skips. Args: planes: The planes, each at least one value long. @@ -157,7 +159,7 @@ def encode_streams( if len(boundaries) != len(planes): raise ValueError(f"a set of boundaries stands for each plane, and {len(boundaries)} stand for {len(planes)}") - present = [plane for plane, written in enumerate(planes) if not is_absent(written)] + present = [plane for plane, written in enumerate(planes) if written] cache = MatchCache(PlaneIndex.from_plane(planes[plane]) for plane in present) monitor = CodecMonitor(report) entries = tuple(boundaries[plane] | {STREAM_START} for plane in present) @@ -197,11 +199,16 @@ def encode_streams( baseline, ) written = iter(emit(parse.tokens) for parse in parses) - streams = tuple(b"" if is_absent(plane) else next(written) for plane in planes) + streams = tuple(next(written) if plane else b"" for plane in planes) monitor.reached(len(table), table.size + sum(len(stream) for stream in streams)) return table, streams +def _returned(boundaries: FrozenSet[int]) -> int: + """The tick a song comes round to, the song's first standing in where it plays once.""" + return min(boundaries, default=STREAM_START) + + def encode_planes( planes: SongPlanes, seeds: Sequence[Phrase], @@ -229,16 +236,32 @@ def encode_planes( OperationCanceled: If ``report`` withdraws the run. """ positions = [planes.positions(tick) for tick in boundaries] + entries = [frozenset(position[index] for position in positions) for index in range(len(planes.planes))] + packed = tuple( + b"" if plane.idles(played) else pack_plane(played, plane.form, boundaries=entry) + for plane, played, entry in zip(PLANES, planes.planes, entries, strict=True) + ) table, streams = encode_streams( - planes.planes, + packed, seeds, options=options, - boundaries=[frozenset(position[plane] for position in positions) for plane in range(len(planes.planes))], + boundaries=[ + symbol_boundaries(played, plane.form, boundaries=entry) + for plane, played, entry in zip(PLANES, planes.planes, entries, strict=True) + ], budget=budget, report=report, ) + returned = [ + symbol_boundaries(played, plane.form, boundaries=frozenset({position})) + for plane, played, position in zip(PLANES, planes.planes, planes.positions(_returned(boundaries)), strict=True) + ] return CompressedPlanes( phrases=table, streams=PlaneOrder.across(streams), ticks=planes.ticks, + loop_entries=tuple( + stream_entry(stream, next(iter(entry), STREAM_START)) if stream else None + for stream, entry in zip(streams, returned, strict=True) + ), ) diff --git a/src/sampletones_tools/codec/study/packing/symbols.py b/src/sampletones_player/compression/planes/symbols.py similarity index 77% rename from src/sampletones_tools/codec/study/packing/symbols.py rename to src/sampletones_player/compression/planes/symbols.py index f2380ab05..6c1093121 100644 --- a/src/sampletones_tools/codec/study/packing/symbols.py +++ b/src/sampletones_player/compression/planes/symbols.py @@ -1,12 +1,11 @@ from typing import FrozenSet, List, Tuple -from sampletones_tools.codec.study.packing.coding import PlaneCoding -from sampletones_tools.codec.study.packing.form import SINGLE_TICK +from sampletones_player.specification.planes import SINGLE_TICK, PlaneForm def pack_plane( - plane: bytes, - form: PlaneCoding, + played: bytes, + form: PlaneForm, *, boundaries: FrozenSet[int], ) -> bytes: @@ -17,7 +16,7 @@ def pack_plane( symbol of its own, so the plane stands at a symbol's first byte wherever a token may start. Args: - plane: The values the plane plays. + played: The values the plane plays. form: How the plane's byte divides. boundaries: The ticks a symbol begins on, beyond the plane's first. @@ -28,11 +27,11 @@ def pack_plane( ValueError: If a value the plane plays carries fixed bits other than the form's. """ symbols = bytearray() - for start, length in _runs(plane, boundaries=boundaries): + for start, length in _runs(played, boundaries=boundaries): held = length while held: repeats = min(held, form.repeats) - symbols.append(form.symbol(plane[start], repeats)) + symbols.append(form.symbol(played[start], repeats)) held -= repeats return bytes(symbols) @@ -40,7 +39,7 @@ def pack_plane( def unpack_plane( symbols: bytes, - form: PlaneCoding, + form: PlaneForm, ) -> bytes: """The values a run of symbols plays, one per tick. @@ -59,8 +58,8 @@ def unpack_plane( def symbol_boundaries( - plane: bytes, - form: PlaneCoding, + played: bytes, + form: PlaneForm, *, boundaries: FrozenSet[int], ) -> FrozenSet[int]: @@ -70,7 +69,7 @@ def symbol_boundaries( ticks and a boundary reaches it as the symbol the tick begins. Args: - plane: The values the plane plays. + played: The values the plane plays. form: How the plane's byte divides. boundaries: The ticks a token starts on, beyond the plane's first. @@ -79,7 +78,7 @@ def symbol_boundaries( """ positions = {} symbol = 0 - for start, length in _runs(plane, boundaries=boundaries): + for start, length in _runs(played, boundaries=boundaries): positions[start] = symbol symbol += -(-length // form.repeats) @@ -87,19 +86,19 @@ def symbol_boundaries( def _runs( - plane: bytes, + played: bytes, *, boundaries: FrozenSet[int], ) -> Tuple[Tuple[int, int], ...]: """Where each run of one value begins and how many ticks it lasts, cut at every boundary.""" runs: List[Tuple[int, int]] = [] start = 0 - for tick in range(SINGLE_TICK, len(plane)): - if plane[tick] != plane[start] or tick in boundaries: + for tick in range(SINGLE_TICK, len(played)): + if played[tick] != played[start] or tick in boundaries: runs.append((start, tick - start)) start = tick - if plane: - runs.append((start, len(plane) - start)) + if played: + runs.append((start, len(played) - start)) return tuple(runs) diff --git a/src/sampletones_player/compression/seeds.py b/src/sampletones_player/compression/seeds.py index 8cd3c5715..466271b51 100644 --- a/src/sampletones_player/compression/seeds.py +++ b/src/sampletones_player/compression/seeds.py @@ -1,4 +1,4 @@ -from typing import AbstractSet, List, Tuple +from typing import AbstractSet, Final, FrozenSet, List, Tuple from sampletones_core.constants.enums import ChannelName from sampletones_core.exporters.maps import CHANNEL_TO_EXPORTER_MAP @@ -8,10 +8,14 @@ from sampletones_player.compression.dictionary.phrase import Phrase from sampletones_player.compression.pitch import PitchTable from sampletones_player.compression.planes.separate import channel_planes +from sampletones_player.compression.planes.symbols import pack_plane from sampletones_player.registers.channel import channel_registers from sampletones_player.specification.compression import MAX_PHRASE_LENGTH +from sampletones_player.specification.planes import PLANES, channel_indices from sampletones_shared.music import Tuning +NO_BOUNDARIES: Final[FrozenSet[int]] = frozenset() + def phrases_from_project( project: Project, @@ -26,7 +30,8 @@ def phrases_from_project( row asks for. A plane holding one value throughout offers the dictionary nothing a hold covers more - cheaply, so the slices seed the planes that turn over. The rows of a song play the slices on + cheaply, so the slices seed the planes that turn over. Each reaches the dictionary as the + symbols its own plane is read in, so a seed and a stream name the same shape. The rows of a song play the slices on the channels it sounds, so those are the slices that seed it. Args: @@ -51,7 +56,11 @@ def phrases_from_project( channel_registers(channel, played, timer_table), pitches, ) - phrases.extend(Phrase(body=plane[:MAX_PHRASE_LENGTH]) for plane in planes if _turns_over(plane)) + phrases.extend( + Phrase(body=pack_plane(plane, PLANES[index].form, boundaries=NO_BOUNDARIES)[:MAX_PHRASE_LENGTH]) + for index, plane in zip(channel_indices(channel), planes, strict=True) + if _turns_over(plane) + ) return tuple(phrases) diff --git a/src/sampletones_player/driver/binary/driver.bin b/src/sampletones_player/driver/binary/driver.bin index 697a28cda2fb993633f2590fe80875ac56741af1..e9016d04f521a971c8ec4f82b2784123dba1bb4a 100644 GIT binary patch delta 566 zcmXAmO=uHA6vt<0lT8|%uO`rmDY7;JT|`I)iKv*Z2N7M07cXAAhZfv}A_%=nb|yYh zA(#;M;Gs|g108V>IpprCBx-Rlibqeq342WaiZgW%|Htq5W|%k3L2{Ptojbch9iN$7 zeoO$Fz8@E-IMU5DH83A$8}5ZD!(E9O7};3Wy?%$q?rL>-Soj_|Da5b`|d zAt;bg>v+d-D#RVH&0taJV2BQ+%n}t1Sb?h|h8^()d{yhkDZH-YmW=wQRQD7;P_gCo>9`#0&Kv5EtFivI>W)cdn4hTgZqV1r+PHFg<&Io| zY?Qzlmm#Hwh0MfyAE%~vTyT~d&seR1s0p3sAku32=82lNB3-`Ayg9)5nl>BUz^gmf z=Y>WW+593DhltnAkBm1@T*BserAzR28(kM%|i@Vq!<+qo<&^Q^;UEOy70n5tY_5c6? delta 515 zcmX|-ziSjh6vt<0f83(Ez1x`c=2Gp&13he#&LH8|F2WY3(C!al;$1Yhq9VO9m4!$p zeKSRAD_wqvBK!FJV{sN{a#@_&GCJ+Lnbj2`6`oS-Qr%Iss^VTxxh?5@g_|lK1u>p1 zm3pv?!wC|WcZ`F5e2yl~(*4jtZ6d9$=G8v!qyVQXAdMBY+dog#P-<}$u`?k{4`nc vS(G?_xN0AQx9(B!k#q+~9CX||l-FJySJ94p0R3@oXYpIVG}Hcp?uY*XUW?7~ diff --git a/src/sampletones_player/nsf/layout.py b/src/sampletones_player/nsf/layout.py index 90f212512..754f384f0 100644 --- a/src/sampletones_player/nsf/layout.py +++ b/src/sampletones_player/nsf/layout.py @@ -3,7 +3,6 @@ from dataclasses import dataclass from typing import Final, Optional, Sequence, Tuple -from sampletones_player.compression.decode import decode_planes from sampletones_player.compression.dictionary.table import PhraseTable from sampletones_player.compression.planes.order import PlaneOrder from sampletones_player.song import Song @@ -14,7 +13,6 @@ ) from sampletones_player.specification.song import ABSENT_STREAM, SONG_HEADER_SIZE -FIRST_TICK: Final[int] = 0 NAME_SEPARATOR: Final[str] = "_" @@ -89,8 +87,7 @@ def of(cls, song: Song) -> SongLayout: stream_start = bodies + sum(body_sizes) stream_offsets = _running(stream_start, stream_sizes) - returned = FIRST_TICK if song.loop_tick is None else song.loop_tick - entered = song.planes.entries(decode_planes(song.planes).positions(returned)) + entered = song.planes.loop_entries return cls( timer_table=timer_table, phrase_table=phrase_table, diff --git a/src/sampletones_player/specification/compression.py b/src/sampletones_player/specification/compression.py index 1a71b9574..75ea8716f 100644 --- a/src/sampletones_player/specification/compression.py +++ b/src/sampletones_player/specification/compression.py @@ -51,4 +51,4 @@ class TokenTag(IntEnum): BEND_FLAG: Final[int] = 0x80 PITCH_INDEX_MASK: Final[int] = BEND_FLAG - 1 -PLANE_STATE_SIZE: Final[int] = 8 +PLANE_STATE_SIZE: Final[int] = 10 diff --git a/src/sampletones_player/specification/planes.py b/src/sampletones_player/specification/planes.py index 366990b08..7df3cc294 100644 --- a/src/sampletones_player/specification/planes.py +++ b/src/sampletones_player/specification/planes.py @@ -3,9 +3,163 @@ from enum import StrEnum from typing import Final, Tuple -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field, model_validator from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.general import MAX_DUTY_CYCLE, MAX_PERIOD, MAX_VOLUME +from sampletones_player.specification.binary import MAX_BYTE_VALUE +from sampletones_player.specification.registers import ( + DUTY_CYCLE_SHIFT, + NOISE_MODE_SHIFT, + SUSTAINED_LEVEL, +) + +SINGLE_TICK: Final[int] = 1 +NO_BITS: Final[int] = 0 +COUNT_STEP: Final[int] = 0x10 + + +class PlaneForm(BaseModel): + """How a plane's byte divides between the value its register reads and a repeat count. + + A channel's register reads some of a byte's bits and ignores the rest: a pulse channel's + volume is four bits under two the hardware wants set, and a noise period is four bits with + three above it that say nothing. A plane writes its value in the bits that reach the register + and counts the ticks that value repeats for in the ones left over, so a run costs one byte + however long it rests. + + The division is a mask, which is what keeps the reading cheap: the register byte is the + symbol masked and ored with the bits the hardware fixes, and one repeat is a subtraction of + ``COUNT_STEP``. A plane whose values need every bit states a full ``value_mask``, carries no + count, and reads byte for byte. + + Attributes: + value_mask: The bits the value occupies. + value_or: The bits the register fixes, which every value of the plane carries. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + value_mask: int = Field(..., ge=0, le=MAX_BYTE_VALUE) + value_or: int = Field(..., ge=0, le=MAX_BYTE_VALUE) + + @model_validator(mode="after") + def _validate_the_fixed_bits_lie_outside_the_value(self) -> PlaneForm: + if self.value_or & self.value_mask: + raise ValueError( + f"the bits a register fixes lie outside the value's own, and {self.value_or:#04x} " + f"meets {self.value_mask:#04x}" + ) + + return self + + @model_validator(mode="after") + def _validate_a_count_steps_from_one_place(self) -> PlaneForm: + if self.counts and self.count_mask & -self.count_mask != COUNT_STEP: + raise ValueError( + f"a repeat count steps from {COUNT_STEP:#04x}, and {self.count_mask:#04x} steps " + f"from {self.count_mask & -self.count_mask:#04x}" + ) + + return self + + @model_validator(mode="after") + def _validate_the_count_occupies_one_run_of_bits(self) -> PlaneForm: + counted = self.count_mask // COUNT_STEP if self.counts else NO_BITS + if counted & (counted + 1): + raise ValueError(f"a repeat count occupies one run of bits, and {self.count_mask:#04x} breaks apart") + + return self + + @property + def count_mask(self) -> int: + """The bits a repeat count occupies.""" + return MAX_BYTE_VALUE ^ self.value_mask + + @property + def counts(self) -> bool: + """Whether the plane's byte has room to count a repeat.""" + return bool(self.count_mask) + + @property + def repeats(self) -> int: + """The ticks one symbol covers at most.""" + if not self.counts: + return SINGLE_TICK + + return self.count_mask // COUNT_STEP + SINGLE_TICK + + def symbol( + self, + value: int, + repeats: int, + ) -> int: + """The byte a plane writes for ``value`` sounding ``repeats`` ticks running. + + Args: + value: The register byte the ticks play. + repeats: The ticks the value sounds for. + + Returns: + int: The symbol. + + Raises: + ValueError: If the value carries fixed bits other than the form's, or the repeats + reach past what one symbol counts. + """ + if value & self.count_mask != self.value_or: + raise ValueError( + f"a value of this plane carries {self.value_or:#04x} in the bits the count takes, " + f"and {value:#04x} carries {value & self.count_mask:#04x}" + ) + + if not SINGLE_TICK <= repeats <= self.repeats: + raise ValueError(f"one symbol counts {SINGLE_TICK} through {self.repeats} ticks, and this counts {repeats}") + + return (value & self.value_mask) | (repeats - SINGLE_TICK) * COUNT_STEP + + def value(self, symbol: int) -> int: + """The register byte ``symbol`` plays.""" + return (symbol & self.value_mask) | self.value_or + + def repeated(self, symbol: int) -> int: + """The ticks ``symbol`` covers.""" + if not self.counts: + return SINGLE_TICK + + return (symbol & self.count_mask) // COUNT_STEP + SINGLE_TICK + + def fits(self, plane: bytes) -> bool: + """Whether every value the plane plays carries the bits this form fixes. + + Args: + plane: The values the plane plays. + + Returns: + bool: Whether the form reads the plane. + """ + return all(value & self.count_mask == self.value_or for value in plane) + + +WHOLE_BYTE: Final[PlaneForm] = PlaneForm(value_mask=MAX_BYTE_VALUE, value_or=NO_BITS) + +DUTY_CYCLE_FIELD: Final[int] = MAX_DUTY_CYCLE << DUTY_CYCLE_SHIFT +VOLUME_FIELD: Final[int] = MAX_VOLUME +PERIOD_FIELD: Final[int] = MAX_PERIOD +NOISE_MODE_FIELD: Final[int] = 1 << NOISE_MODE_SHIFT + +PULSE_CONTROL_FORM: Final[PlaneForm] = PlaneForm( + value_mask=DUTY_CYCLE_FIELD | VOLUME_FIELD, + value_or=SUSTAINED_LEVEL, +) +NOISE_CONTROL_FORM: Final[PlaneForm] = PlaneForm( + value_mask=VOLUME_FIELD, + value_or=SUSTAINED_LEVEL, +) +NOISE_VALUE_FORM: Final[PlaneForm] = PlaneForm( + value_mask=NOISE_MODE_FIELD | PERIOD_FIELD, + value_or=NO_BITS, +) class PlaneRole(StrEnum): @@ -28,12 +182,26 @@ class Plane(BaseModel): Attributes: channel: The channel the plane belongs to. role: The part of the channel's tick the plane carries. + form: How the plane's byte divides between its value and a repeat count. + seeded: The register byte the plane stands at before its first token, which the driver + writes when it starts a song and a plane holding it throughout takes no stream. """ model_config = ConfigDict(extra="forbid", frozen=True) channel: ChannelName role: PlaneRole + form: PlaneForm + seeded: int = Field(..., ge=0, le=MAX_BYTE_VALUE) + + @model_validator(mode="after") + def _validate_the_seeded_value_reads_as_the_plane_does(self) -> Plane: + if not self.form.fits(bytes((self.seeded,))): + raise ValueError( + f"a plane stands at a value its own form reads, and {self.name} stands at {self.seeded:#04x}" + ) + + return self @property def name(self) -> str: @@ -45,19 +213,57 @@ def spans_flagged_ticks(self) -> bool: """Whether the plane holds a value per flagged tick rather than one per tick.""" return self.role is PlaneRole.BEND + def idles(self, played: bytes) -> bool: + """Whether the plane holds the value it is seeded to throughout, so it takes no stream. + + Args: + played: The values the plane plays. + + Returns: + bool: Whether the plane stands where the driver seeds it. + """ + return set(played) <= {self.seeded} + + +def _tone( + channel: ChannelName, + role: PlaneRole, +) -> Plane: + return Plane(channel=channel, role=role, form=WHOLE_BYTE, seeded=NO_BITS) + PLANES: Final[Tuple[Plane, ...]] = ( - Plane(channel=ChannelName.PULSE1, role=PlaneRole.CONTROL), - Plane(channel=ChannelName.PULSE1, role=PlaneRole.VALUE), - Plane(channel=ChannelName.PULSE1, role=PlaneRole.BEND), - Plane(channel=ChannelName.PULSE2, role=PlaneRole.CONTROL), - Plane(channel=ChannelName.PULSE2, role=PlaneRole.VALUE), - Plane(channel=ChannelName.PULSE2, role=PlaneRole.BEND), - Plane(channel=ChannelName.TRIANGLE, role=PlaneRole.CONTROL), - Plane(channel=ChannelName.TRIANGLE, role=PlaneRole.VALUE), - Plane(channel=ChannelName.TRIANGLE, role=PlaneRole.BEND), - Plane(channel=ChannelName.NOISE, role=PlaneRole.CONTROL), - Plane(channel=ChannelName.NOISE, role=PlaneRole.VALUE), + Plane( + channel=ChannelName.PULSE1, + role=PlaneRole.CONTROL, + form=PULSE_CONTROL_FORM, + seeded=SUSTAINED_LEVEL, + ), + _tone(ChannelName.PULSE1, PlaneRole.VALUE), + _tone(ChannelName.PULSE1, PlaneRole.BEND), + Plane( + channel=ChannelName.PULSE2, + role=PlaneRole.CONTROL, + form=PULSE_CONTROL_FORM, + seeded=SUSTAINED_LEVEL, + ), + _tone(ChannelName.PULSE2, PlaneRole.VALUE), + _tone(ChannelName.PULSE2, PlaneRole.BEND), + _tone(ChannelName.TRIANGLE, PlaneRole.CONTROL), + _tone(ChannelName.TRIANGLE, PlaneRole.VALUE), + _tone(ChannelName.TRIANGLE, PlaneRole.BEND), + Plane( + channel=ChannelName.NOISE, + role=PlaneRole.CONTROL, + form=NOISE_CONTROL_FORM, + seeded=SUSTAINED_LEVEL, + ), + Plane( + channel=ChannelName.NOISE, + role=PlaneRole.VALUE, + form=NOISE_VALUE_FORM, + seeded=NO_BITS, + ), ) PLANE_COUNT: Final[int] = len(PLANES) PLANE_NAMES: Final[Tuple[str, ...]] = tuple(plane.name for plane in PLANES) diff --git a/src/sampletones_tools/codec/absent.py b/src/sampletones_tools/codec/absent.py new file mode 100644 index 000000000..8193dcd2e --- /dev/null +++ b/src/sampletones_tools/codec/absent.py @@ -0,0 +1,16 @@ +from sampletones_player.specification.compression import INITIAL_PLANE_VALUE + + +def is_absent(played: bytes) -> bool: + """Whether a plane a study prices holds the value a plane starts at throughout. + + A study weighs layouts whose planes answer to no plane of the song block, so it reads + absence off the values alone rather than off the plane the block would write. + + Args: + played: The values the plane plays. + + Returns: + bool: Whether the plane holds that value and no other. + """ + return set(played) <= {INITIAL_PLANE_VALUE} diff --git a/src/sampletones_tools/codec/report/encoding.py b/src/sampletones_tools/codec/report/encoding.py index 26597f7ac..4509d65f8 100644 --- a/src/sampletones_tools/codec/report/encoding.py +++ b/src/sampletones_tools/codec/report/encoding.py @@ -3,7 +3,6 @@ from typing import Final, List, Sequence, Tuple from sampletones_core.constants.enums import PULSE_CHANNELS -from sampletones_player.compression.absent import is_absent from sampletones_player.compression.compressed import CompressedPlanes from sampletones_player.compression.dictionary.table import phrase_table from sampletones_player.compression.encode import STREAM_START, encode_planes @@ -17,6 +16,7 @@ from sampletones_player.registers.streams import ChannelStreams from sampletones_player.specification.planes import PLANES, PlaneRole from sampletones_player.specification.registers import DUTY_CYCLE_SHIFT +from sampletones_tools.codec.absent import is_absent from sampletones_tools.codec.report.corpus import CorpusEntry from sampletones_tools.codec.report.rows import ReportRow diff --git a/src/sampletones_tools/codec/study/accounting/fixed.py b/src/sampletones_tools/codec/study/accounting/fixed.py index ee7965e79..3a2a8f2a6 100644 --- a/src/sampletones_tools/codec/study/accounting/fixed.py +++ b/src/sampletones_tools/codec/study/accounting/fixed.py @@ -1,13 +1,13 @@ from dataclasses import dataclass from typing import Final +from sampletones_core.constants.enums import TONE_CHANNELS from sampletones_player.compression.compressed import CompressedPlanes from sampletones_player.compression.planes.flags import pitch_index from sampletones_player.specification.binary import WORD_SIZE from sampletones_player.specification.compression import ( PHRASE_TABLE_ENTRY_SIZE, ) -from sampletones_core.constants.enums import TONE_CHANNELS from sampletones_player.specification.planes import PLANE_COUNT, PLANES, PlaneRole from sampletones_player.specification.song import SONG_HEADER_SIZE from sampletones_tools.codec.study.accounting.finding import Finding diff --git a/src/sampletones_tools/codec/study/layouts/encode.py b/src/sampletones_tools/codec/study/layouts/encode.py index f0e121a8a..816a13283 100644 --- a/src/sampletones_tools/codec/study/layouts/encode.py +++ b/src/sampletones_tools/codec/study/layouts/encode.py @@ -3,6 +3,8 @@ from sampletones_player.compression.decode import decode_plane from sampletones_player.compression.encode import encode_streams from sampletones_player.compression.options import EVERY_LAYER +from sampletones_player.compression.planes.symbols import pack_plane +from sampletones_player.specification.planes import PLANES from sampletones_player.specification.song import SONG_HEADER_SIZE from sampletones_tools.codec.study.corpus.song import StudySong from sampletones_tools.codec.study.layouts.layout import PlaneLayout @@ -26,13 +28,17 @@ def encode_layout( """ planes = layout_planes(song, layout) seeds = layout_seeds(song.slices, song.pitches, layout) + packed = tuple( + b"" if plane.idles(played) else pack_plane(played, plane.form, boundaries=NO_LOOP_BOUNDARIES) + for plane, played in zip(PLANES, planes, strict=True) + ) started = process_time() table, streams = encode_streams( - planes, + packed, seeds, options=EVERY_LAYER, - boundaries=(NO_LOOP_BOUNDARIES,) * len(planes), + boundaries=(NO_LOOP_BOUNDARIES,) * len(packed), ) seconds = process_time() - started @@ -43,7 +49,8 @@ def encode_layout( streams=tuple(len(stream) for stream in streams), seconds=seconds, lossless=all( - decode_plane(stream, table, len(plane)) == plane for plane, stream in zip(planes, streams, strict=True) + decode_plane(stream, table, plane, len(played)) == played + for plane, played, stream in zip(PLANES, planes, streams, strict=True) ), written=None, ) diff --git a/src/sampletones_tools/codec/study/layouts/planes.py b/src/sampletones_tools/codec/study/layouts/planes.py index 01a72e011..8f8f130d9 100644 --- a/src/sampletones_tools/codec/study/layouts/planes.py +++ b/src/sampletones_tools/codec/study/layouts/planes.py @@ -1,13 +1,17 @@ -from typing import List, Sequence, Tuple +from typing import Final, FrozenSet, List, Sequence, Tuple from sampletones_core.constants.enums import TONE_CHANNELS, ChannelName from sampletones_player.compression.dictionary.phrase import Phrase from sampletones_player.compression.pitch import PitchTable +from sampletones_player.compression.planes.symbols import pack_plane from sampletones_player.specification.compression import MAX_PHRASE_LENGTH +from sampletones_player.specification.planes import PLANES, channel_indices from sampletones_tools.codec.study.corpus.song import StudySlice, StudySong from sampletones_tools.codec.study.layouts.layout import PlaneLayout from sampletones_tools.codec.study.layouts.tone import tone_planes +NO_BOUNDARIES: Final[FrozenSet[int]] = frozenset() + def layout_planes( song: StudySong, @@ -57,6 +61,10 @@ def layout_seeds( else study_slice.planes ) - phrases.extend(Phrase(body=plane[:MAX_PHRASE_LENGTH]) for plane in planes if len(set(plane)) > 1) + phrases.extend( + Phrase(body=pack_plane(plane, PLANES[index].form, boundaries=NO_BOUNDARIES)[:MAX_PHRASE_LENGTH]) + for index, plane in zip(channel_indices(study_slice.channel), planes, strict=True) + if len(set(plane)) > 1 + ) return tuple(phrases) diff --git a/src/sampletones_tools/codec/study/packing/__init__.py b/src/sampletones_tools/codec/study/packing/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/sampletones_tools/codec/study/packing/coding.py b/src/sampletones_tools/codec/study/packing/coding.py deleted file mode 100644 index 580eef3d6..000000000 --- a/src/sampletones_tools/codec/study/packing/coding.py +++ /dev/null @@ -1,35 +0,0 @@ -from typing import Protocol - - -class PlaneCoding(Protocol): - """What one byte of a plane stands for: a value its register reads, and the ticks it repeats. - - A plane spends its byte on a value alone or divides it, keeping the value in part of the byte - and counting repeats in the rest. Either way a stream is read the same way — a symbol names a - value and the ticks that value sounds for — so the packing and the parse read a coding through - this and leave how the division is reached to the coding itself. - """ - - @property - def repeats(self) -> int: - """The ticks one symbol covers at most.""" - - @property - def counts(self) -> bool: - """Whether the byte has room to count a repeat.""" - - @property - def seeded(self) -> int: - """The register byte the plane stands at before its first token.""" - - def symbol(self, value: int, repeats: int) -> int: - """The byte the plane writes for ``value`` sounding ``repeats`` ticks running.""" - - def value(self, symbol: int) -> int: - """The register byte ``symbol`` plays.""" - - def repeated(self, symbol: int) -> int: - """The ticks ``symbol`` covers.""" - - def idles(self, plane: bytes) -> bool: - """Whether the plane holds the value it is seeded to throughout, so it takes no stream.""" diff --git a/src/sampletones_tools/codec/study/packing/encode.py b/src/sampletones_tools/codec/study/packing/encode.py deleted file mode 100644 index eed1e3174..000000000 --- a/src/sampletones_tools/codec/study/packing/encode.py +++ /dev/null @@ -1,115 +0,0 @@ -from time import process_time -from typing import Final, FrozenSet, Sequence, Tuple - -from sampletones_player.compression.decode import decode_plane -from sampletones_player.compression.dictionary.table import PhraseTable -from sampletones_player.compression.encode import encode_streams -from sampletones_player.compression.options import EVERY_LAYER, CodecOptions -from sampletones_player.specification.song import SONG_HEADER_SIZE -from sampletones_tools.codec.study.corpus.song import StudySong -from sampletones_tools.codec.study.measure import Encoding -from sampletones_tools.codec.study.packing.coding import PlaneCoding -from sampletones_tools.codec.study.packing.scheme import ( - PackingScheme, - plane_codings, - stated_bytes, -) -from sampletones_tools.codec.study.packing.symbols import pack_plane, unpack_plane - -NO_LOOP_BOUNDARIES: Final[FrozenSet[int]] = frozenset() -IDLE_PLANE: Final[bytes] = b"" - - -def packed_symbols( - planes: Sequence[bytes], - codings: Sequence[PlaneCoding], -) -> Tuple[bytes, ...]: - """Each plane as the symbols it plays, a plane holding its seeded value taking none. - - Args: - planes: The planes, in the order the song block writes them. - codings: How each plane's byte divides. - - Returns: - Tuple[bytes, ...]: The symbols per plane, an idle plane's empty so the block leaves it out. - """ - return tuple( - IDLE_PLANE if coding.idles(plane) else pack_plane(plane, coding, boundaries=NO_LOOP_BOUNDARIES) - for plane, coding in zip(planes, codings, strict=True) - ) - - -def plays_back( - planes: Sequence[bytes], - codings: Sequence[PlaneCoding], - symbols: Sequence[bytes], - streams: Sequence[bytes], - table: PhraseTable, -) -> bool: - """Whether the streams read back as the ticks the planes play. - - Args: - planes: The planes, in the order the song block writes them. - codings: How each plane's byte divides. - symbols: The symbols each plane was written from. - streams: The token stream each plane was written as. - table: The dictionary the tokens name. - - Returns: - bool: Whether every plane comes back as it stands. - """ - for plane, coding, packed, stream in zip(planes, codings, symbols, streams, strict=True): - if not stream: - if not coding.idles(plane): - return False - - continue - - if unpack_plane(decode_plane(stream, table, len(packed)), coding) != plane: - return False - - return True - - -def encode_packed( - song: StudySong, - scheme: PackingScheme, - *, - options: CodecOptions = EVERY_LAYER, -) -> Encoding: - """Encodes a song whose planes carry a repeat count in the bits their register leaves alone. - - The planes are packed into symbols and the production codec reads those, so a token's count - names symbols where it named ticks and a hold repeats the symbol the plane reached — the - reach of every token multiplying by what its symbol counts. - - Args: - song: The song to encode. - scheme: Where each plane's division between value and count comes from. - options: Which of the codec's layers the encoding is built from. - - Returns: - Encoding: The encoding, the block's header grown by what the scheme states. - """ - planes = tuple(song.planes.planes) - codings = plane_codings(planes, scheme) - symbols = packed_symbols(planes, codings) - - started = process_time() - table, streams = encode_streams( - symbols, - (), - options=options, - boundaries=(NO_LOOP_BOUNDARIES,) * len(symbols), - ) - seconds = process_time() - started - - return Encoding( - header=SONG_HEADER_SIZE + stated_bytes(codings, scheme), - phrases=len(table), - dictionary=table.size, - streams=tuple(len(stream) for stream in streams), - seconds=seconds, - lossless=plays_back(planes, codings, symbols, streams, table), - written=None, - ) diff --git a/src/sampletones_tools/codec/study/packing/fitted.py b/src/sampletones_tools/codec/study/packing/fitted.py deleted file mode 100644 index 31655e358..000000000 --- a/src/sampletones_tools/codec/study/packing/fitted.py +++ /dev/null @@ -1,74 +0,0 @@ -from typing import Final, Tuple - -from sampletones_player.specification.binary import MAX_BYTE_VALUE -from sampletones_shared.constants.general import BITS_PER_BYTE -from sampletones_tools.codec.study.packing.form import NO_BITS, WHOLE_BYTE, PlaneForm - -FORM_SIZE: Final[int] = 2 - - -def fitted_form(plane: bytes) -> PlaneForm: - """The narrowest form the plane's own values allow. - - A register fixes some of a byte's bits for every song, and a song fixes more of its own: a - triangle channel names its counter control in every tick, and a bass line reaching only the - low notes leaves the index's top bits standing at zero. Whichever bits the plane never turns - over are free, and the longest run of them carries the repeat count. - - Args: - plane: The values the plane plays. - - Returns: - PlaneForm: The form, a plane turning over every bit taking its whole byte. - """ - if not plane: - return WHOLE_BYTE - - count_mask = _longest_run(_settled_bits(plane)) - return PlaneForm( - value_mask=MAX_BYTE_VALUE ^ count_mask, - value_or=plane[0] & count_mask, - ) - - -def _settled_bits(plane: bytes) -> int: - """The bits every value of the plane agrees on.""" - settled = MAX_BYTE_VALUE - first = plane[0] - for value in plane: - settled &= MAX_BYTE_VALUE ^ (value ^ first) - - return settled - - -def _longest_run(settled: int) -> int: - """The longest run of set bits in ``settled``, as the mask it covers.""" - runs: Tuple[int, ...] = tuple(_runs(settled)) - if not runs: - return NO_BITS - - return max(runs, key=_width) - - -def _runs(settled: int) -> Tuple[int, ...]: - found = [] - run = NO_BITS - for bit in range(BITS_PER_BYTE): - position = 1 << bit - if settled & position: - run |= position - continue - - if run: - found.append(run) - - run = NO_BITS - - if run: - found.append(run) - - return tuple(found) - - -def _width(run: int) -> int: - return bin(run).count("1") diff --git a/src/sampletones_tools/codec/study/packing/form.py b/src/sampletones_tools/codec/study/packing/form.py deleted file mode 100644 index 1bf1f94af..000000000 --- a/src/sampletones_tools/codec/study/packing/form.py +++ /dev/null @@ -1,152 +0,0 @@ -from __future__ import annotations - -from typing import Final - -from pydantic import BaseModel, ConfigDict, Field, model_validator - -from sampletones_player.specification.binary import MAX_BYTE_VALUE - -SINGLE_TICK: Final[int] = 1 -NO_BITS: Final[int] = 0 - - -class PlaneForm(BaseModel): - """How a plane's byte divides between the value its register reads and a repeat count. - - A channel's register reads some of a byte's bits and ignores the rest: a pulse channel's - volume is four bits under two the hardware wants set, and a noise period is four bits with - three above it that say nothing. A plane writes its value in the bits that reach the register - and counts the ticks that value repeats for in the ones left over, so a run costs one byte - however long it rests. - - The division is a mask, which is what keeps the reading cheap: the register byte is the - symbol masked and ored with the bits the hardware fixes, and one repeat is a subtraction of - ``count_step``. A plane whose values need every bit states a full ``value_mask``, carries no - count, and reads byte for byte as it does today. - - Attributes: - value_mask: The bits the value occupies. - value_or: The bits the register fixes, which every value of the plane carries. - """ - - model_config = ConfigDict(extra="forbid", frozen=True) - - value_mask: int = Field(..., ge=0, le=MAX_BYTE_VALUE) - value_or: int = Field(..., ge=0, le=MAX_BYTE_VALUE) - - @model_validator(mode="after") - def _validate_the_fixed_bits_lie_outside_the_value(self) -> PlaneForm: - if self.value_or & self.value_mask: - raise ValueError( - f"the bits a register fixes lie outside the value's own, and {self.value_or:#04x} " - f"meets {self.value_mask:#04x}" - ) - - return self - - @model_validator(mode="after") - def _validate_the_count_occupies_one_run_of_bits(self) -> PlaneForm: - if not self.count_mask: - return self - - counted = self.count_mask // self.count_step - if counted & (counted + 1): - raise ValueError( - f"a repeat count occupies one run of bits, and the value breaks {self.count_mask:#04x} apart" - ) - - return self - - @property - def count_mask(self) -> int: - """The bits a repeat count occupies.""" - return MAX_BYTE_VALUE ^ self.value_mask - - @property - def count_step(self) -> int: - """What one further repeat adds to a symbol.""" - return self.count_mask & -self.count_mask - - @property - def repeats(self) -> int: - """The ticks one symbol covers at most.""" - if not self.count_mask: - return SINGLE_TICK - - return self.count_mask // self.count_step + 1 - - @property - def counts(self) -> bool: - """Whether the plane's byte has room to count a repeat.""" - return self.repeats > SINGLE_TICK - - def symbol(self, value: int, repeats: int) -> int: - """The byte a plane writes for ``value`` sounding ``repeats`` ticks running. - - Args: - value: The register byte the ticks play. - repeats: The ticks the value sounds for, up to ``repeats``. - - Returns: - int: The symbol. - - Raises: - ValueError: If the value carries fixed bits other than the form's, or the repeats - reach past what one symbol counts. - """ - if value & self.count_mask != self.value_or: - raise ValueError( - f"a value of this plane carries {self.value_or:#04x} in the bits the count takes, " - f"and {value:#04x} carries {value & self.count_mask:#04x}" - ) - - if not SINGLE_TICK <= repeats <= self.repeats: - raise ValueError(f"one symbol counts {SINGLE_TICK} through {self.repeats} ticks, and this counts {repeats}") - - return (value & self.value_mask) | (repeats - SINGLE_TICK) * self.count_step - - def value(self, symbol: int) -> int: - """The register byte ``symbol`` plays.""" - return (symbol & self.value_mask) | self.value_or - - def repeated(self, symbol: int) -> int: - """The ticks ``symbol`` covers.""" - if not self.count_mask: - return SINGLE_TICK - - return (symbol & self.count_mask) // self.count_step + SINGLE_TICK - - @property - def seeded(self) -> int: - """The register byte a plane stands at before its first token. - - The driver seeds every plane to the value zero states, which under a form is the bits the - register fixes: a pulse channel's control byte seeds to a sustained silence rather than to - nothing at all. A plane holding that value throughout therefore takes no stream. - """ - return self.value(NO_BITS) - - def idles(self, plane: bytes) -> bool: - """Whether the plane holds the value it is seeded to throughout, so it takes no stream. - - Args: - plane: The values the plane plays. - - Returns: - bool: Whether the plane leaves its seeded value. - """ - return set(plane) <= {self.seeded} - - def fits(self, plane: bytes) -> bool: - """Whether every value the plane plays carries the bits this form fixes. - - Args: - plane: The values the plane plays. - - Returns: - bool: Whether the form reads the plane. - """ - return all(value & self.count_mask == self.value_or for value in plane) - - -WHOLE_BYTE: Final[PlaneForm] = PlaneForm(value_mask=MAX_BYTE_VALUE, value_or=NO_BITS) diff --git a/src/sampletones_tools/codec/study/packing/grammar.py b/src/sampletones_tools/codec/study/packing/grammar.py deleted file mode 100644 index b92e5b1a7..000000000 --- a/src/sampletones_tools/codec/study/packing/grammar.py +++ /dev/null @@ -1,101 +0,0 @@ -from dataclasses import replace -from time import process_time -from typing import Tuple - -from sampletones_player.compression.absent import is_absent -from sampletones_player.compression.compressed import CompressedPlanes -from sampletones_player.compression.encode import encode_streams -from sampletones_player.compression.matches.cache import MatchCache -from sampletones_player.compression.matches.index import PlaneIndex -from sampletones_player.compression.options import EVERY_LAYER -from sampletones_player.compression.planes.order import PlaneOrder -from sampletones_player.specification.song import SONG_HEADER_SIZE -from sampletones_tools.codec.study.corpus.song import StudySong -from sampletones_tools.codec.study.measure import Encoding -from sampletones_tools.codec.study.packing.encode import NO_LOOP_BOUNDARIES, packed_symbols -from sampletones_tools.codec.study.packing.scheme import ( - PackingScheme, - plane_codings, - stated_bytes, -) -from sampletones_tools.codec.study.sandbox.defaults import no_defaults -from sampletones_tools.codec.study.sandbox.encode import encode_grammar -from sampletones_tools.codec.study.sandbox.grammar import BASELINE_GRAMMAR, Grammar -from sampletones_tools.codec.study.sandbox.reference import Reference, plane_parses -from sampletones_tools.codec.study.sandbox.verify import verify_baseline - - -def packed_reference( - song: StudySong, - scheme: PackingScheme, -) -> Tuple[Reference, int]: - """A song's packed planes as every grammar is priced against them. - - The production codec settles a dictionary over the packed symbols, and the sandbox's - baseline grammar is held to the streams it wrote, exactly as it is over a song's own - planes. A grammar is then read on those streams alone. - - Args: - song: The song. - scheme: Where each plane's division between value and count comes from. - - Returns: - Tuple[Reference, int]: What the grammars are priced against, and the bytes the block's - header takes under the scheme. - - Raises: - ValueError: If the baseline grammar prices a packed plane differently from the stream - the codec wrote. - """ - planes = tuple(song.planes.planes) - codings = plane_codings(planes, scheme) - symbols = packed_symbols(planes, codings) - table, streams = encode_streams( - symbols, - (), - options=EVERY_LAYER, - boundaries=(NO_LOOP_BOUNDARIES,) * len(symbols), - ) - compressed = CompressedPlanes( - phrases=table, - streams=PlaneOrder.across(streams), - ticks=song.ticks, - ) - cache = MatchCache(PlaneIndex.from_plane(plane) for plane in symbols if not is_absent(plane)) - baseline = plane_parses(symbols, cache, table, BASELINE_GRAMMAR, no_defaults(len(table))) - verify_baseline(baseline, compressed) - return ( - Reference( - song=song, - planes=symbols, - table=table, - cache=cache, - baseline=baseline, - ), - SONG_HEADER_SIZE + stated_bytes(codings, scheme), - ) - - -def encode_packed_grammar( - song: StudySong, - scheme: PackingScheme, - grammar: Grammar, -) -> Encoding: - """Prices a song's packed planes under a grammar, over the dictionary the codec settled on. - - Args: - song: The song to encode. - scheme: Where each plane's division between value and count comes from. - grammar: The grammar. - - Returns: - Encoding: The bytes each stream would take, timed, and whether the tokens play back. - """ - started = process_time() - reference, header = packed_reference(song, scheme) - priced = encode_grammar(reference, grammar) - return replace( - priced, - header=header, - seconds=process_time() - started, - ) diff --git a/src/sampletones_tools/codec/study/packing/registers.py b/src/sampletones_tools/codec/study/packing/registers.py deleted file mode 100644 index abd909405..000000000 --- a/src/sampletones_tools/codec/study/packing/registers.py +++ /dev/null @@ -1,60 +0,0 @@ -from typing import Dict, Final, Tuple - -from sampletones_core.constants.general import MAX_DUTY_CYCLE, MAX_PERIOD, MAX_VOLUME -from sampletones_player.compression.planes.order import PlaneOrder -from sampletones_player.specification.registers import ( - DUTY_CYCLE_SHIFT, - NOISE_MODE_SHIFT, - SUSTAINED_LEVEL, -) -from sampletones_tools.codec.study.packing.form import NO_BITS, WHOLE_BYTE, PlaneForm - -DUTY_CYCLE_FIELD: Final[int] = MAX_DUTY_CYCLE << DUTY_CYCLE_SHIFT -VOLUME_FIELD: Final[int] = MAX_VOLUME -PERIOD_FIELD: Final[int] = MAX_PERIOD -NOISE_MODE_FIELD: Final[int] = 1 << NOISE_MODE_SHIFT - -PULSE_CONTROL_FORM: Final[PlaneForm] = PlaneForm( - value_mask=DUTY_CYCLE_FIELD | VOLUME_FIELD, - value_or=SUSTAINED_LEVEL, -) -NOISE_CONTROL_FORM: Final[PlaneForm] = PlaneForm( - value_mask=VOLUME_FIELD, - value_or=SUSTAINED_LEVEL, -) -NOISE_VALUE_FORM: Final[PlaneForm] = PlaneForm( - value_mask=NOISE_MODE_FIELD | PERIOD_FIELD, - value_or=NO_BITS, -) - -PLANE_FORMS: Final[Dict[str, PlaneForm]] = { - "pulse1_control": PULSE_CONTROL_FORM, - "pulse1_value": WHOLE_BYTE, - "pulse1_bend": WHOLE_BYTE, - "pulse2_control": PULSE_CONTROL_FORM, - "pulse2_value": WHOLE_BYTE, - "pulse2_bend": WHOLE_BYTE, - "triangle_control": WHOLE_BYTE, - "triangle_value": WHOLE_BYTE, - "triangle_bend": WHOLE_BYTE, - "noise_control": NOISE_CONTROL_FORM, - "noise_value": NOISE_VALUE_FORM, -} - - -def register_forms() -> Tuple[PlaneForm, ...]: - """The form each plane's own register fixes, in the order the song block writes the planes. - - A pulse channel's control byte holds its duty cycle and volume around two bits the hardware - wants set, a noise channel's control byte holds a volume under the same two, and a noise - period byte holds a mode bit above three the register reads nothing from. Those are the bits - a repeat count rides in without the block stating anything. Every other plane spends its - whole byte, so it reads as it does today. - - Returns: - Tuple[PlaneForm, ...]: One form per plane, in song-block order. - - Raises: - KeyError: If a plane the song block writes names no form. - """ - return tuple(PLANE_FORMS[name] for name in PlaneOrder.names()) diff --git a/src/sampletones_tools/codec/study/packing/scheme.py b/src/sampletones_tools/codec/study/packing/scheme.py deleted file mode 100644 index 21b54817a..000000000 --- a/src/sampletones_tools/codec/study/packing/scheme.py +++ /dev/null @@ -1,75 +0,0 @@ -from enum import StrEnum -from typing import Sequence, Tuple - -from sampletones_tools.codec.study.packing.coding import PlaneCoding -from sampletones_tools.codec.study.packing.fitted import FORM_SIZE, fitted_form -from sampletones_tools.codec.study.packing.registers import register_forms -from sampletones_tools.codec.study.packing.table import TableForm - - -class PackingScheme(StrEnum): - """Where a plane's division between its value and its repeat count comes from. - - Attributes: - FIXED: The bits the plane's own register leaves alone, which every song shares, so the - block states nothing and the driver knows each plane's mask by the plane it is. - FITTED: The bits the song's own values leave alone, which reaches further and which the - block states as a mask and the bits the register fixes. - TABLE: The plane's distinct values gathered into a table the block states, which reaches - a plane whose values spread across its byte. - """ - - FIXED = "fixed" - FITTED = "fitted" - TABLE = "table" - - -def plane_codings( - planes: Sequence[bytes], - scheme: PackingScheme, -) -> Tuple[PlaneCoding, ...]: - """How each plane's byte divides under a scheme. - - Args: - planes: The planes, in the order the song block writes them. - scheme: Where the division comes from. - - Returns: - Tuple[PlaneCoding, ...]: One coding per plane, in the order given. - """ - match scheme: - case PackingScheme.FIXED: - return tuple(register_forms()) - case PackingScheme.FITTED: - return tuple(fitted_form(plane) for plane in planes) - case PackingScheme.TABLE: - return tuple(_table(plane) for plane in planes) - - -def stated_bytes( - codings: Sequence[PlaneCoding], - scheme: PackingScheme, -) -> int: - """The bytes the song block takes to state how its planes are read. - - Args: - codings: How each plane's byte divides. - scheme: Where the division comes from. - - Returns: - int: The bytes the block states beyond what it states today. - """ - match scheme: - case PackingScheme.FIXED: - return 0 - case PackingScheme.FITTED: - return FORM_SIZE * len(codings) - case PackingScheme.TABLE: - return sum(coding.stated for coding in codings if isinstance(coding, TableForm)) - - -def _table(plane: bytes) -> PlaneCoding: - if not plane: - return fitted_form(plane) - - return TableForm.across(plane) diff --git a/src/sampletones_tools/codec/study/packing/table.py b/src/sampletones_tools/codec/study/packing/table.py deleted file mode 100644 index e405ce71c..000000000 --- a/src/sampletones_tools/codec/study/packing/table.py +++ /dev/null @@ -1,142 +0,0 @@ -from __future__ import annotations - -from typing import Dict, Final, Tuple - -from pydantic import BaseModel, ConfigDict, model_validator - -from sampletones_player.specification.binary import MAX_BYTE_VALUE, WORD_SIZE -from sampletones_tools.codec.study.packing.form import SINGLE_TICK - -TABLE_LENGTH_SIZE: Final[int] = 1 -TABLE_OFFSET_SIZE: Final[int] = WORD_SIZE - - -class TableForm(BaseModel): - """A plane's distinct values gathered into a table, its byte naming one of them and a count. - - A mask frees only the bits a register leaves alone, and a plane playing a handful of values - spread across its byte frees none — a triangle channel sounding or resting turns over seven - bits to say one thing. Listing what the plane plays gives every such plane a short code, and - the bits the code leaves carry the repeat count. - - The code is the value's place in the table, so the table leads with the value the plane is - seeded to and the driver reaches a register byte by one indexed load. - - Attributes: - values: The register byte each code stands for, in code order. - """ - - model_config = ConfigDict(extra="forbid", frozen=True) - - values: Tuple[int, ...] - - @model_validator(mode="after") - def _validate_the_table_names_each_value_once(self) -> TableForm: - if not self.values: - raise ValueError("a table names at least one value") - - if len(set(self.values)) != len(self.values): - raise ValueError("a table names each value once, and this one repeats a value") - - outside = tuple(value for value in self.values if not 0 <= value <= MAX_BYTE_VALUE) - if outside: - raise ValueError(f"a table names register bytes, and it names {outside}") - - return self - - @classmethod - def across(cls, plane: bytes) -> TableForm: - """The table a plane's own values fill, in the order they sound from quietest upward. - - Args: - plane: The values the plane plays. - - Returns: - TableForm: The table. - """ - return cls(values=tuple(sorted(set(plane)))) - - @property - def code_bits(self) -> int: - """The bits a code takes.""" - return (len(self.values) - 1).bit_length() - - @property - def value_mask(self) -> int: - """The bits the code occupies.""" - return (1 << self.code_bits) - 1 - - @property - def count_mask(self) -> int: - """The bits a repeat count occupies.""" - return MAX_BYTE_VALUE ^ self.value_mask - - @property - def count_step(self) -> int: - """What one further repeat adds to a symbol.""" - return 1 << self.code_bits - - @property - def repeats(self) -> int: - """The ticks one symbol covers at most.""" - return self.count_mask // self.count_step + 1 if self.count_mask else SINGLE_TICK - - @property - def counts(self) -> bool: - """Whether the plane's byte has room to count a repeat.""" - return self.repeats > SINGLE_TICK - - @property - def seeded(self) -> int: - """The register byte the plane stands at before its first token.""" - return self.values[0] - - @property - def stated(self) -> int: - """The bytes the song block takes to state the table.""" - return TABLE_OFFSET_SIZE + TABLE_LENGTH_SIZE + len(self.values) - - def symbol(self, value: int, repeats: int) -> int: - """The byte the plane writes for ``value`` sounding ``repeats`` ticks running. - - Args: - value: The register byte the ticks play. - repeats: The ticks the value sounds for. - - Returns: - int: The symbol. - - Raises: - ValueError: If the table names no such value, or the repeats reach past what one - symbol counts. - """ - if not SINGLE_TICK <= repeats <= self.repeats: - raise ValueError(f"one symbol counts {SINGLE_TICK} through {self.repeats} ticks, and this counts {repeats}") - - return self._codes[value] | (repeats - SINGLE_TICK) * self.count_step - - def value(self, symbol: int) -> int: - """The register byte ``symbol`` plays.""" - return self.values[symbol & self.value_mask] - - def repeated(self, symbol: int) -> int: - """The ticks ``symbol`` covers.""" - if not self.count_mask: - return SINGLE_TICK - - return (symbol & self.count_mask) // self.count_step + SINGLE_TICK - - def idles(self, plane: bytes) -> bool: - """Whether the plane holds the value it is seeded to throughout, so it takes no stream. - - Args: - plane: The values the plane plays. - - Returns: - bool: Whether the plane leaves its seeded value. - """ - return set(plane) <= {self.seeded} - - @property - def _codes(self) -> Dict[int, int]: - return {value: code for code, value in enumerate(self.values)} diff --git a/src/sampletones_tools/codec/study/sandbox/reference.py b/src/sampletones_tools/codec/study/sandbox/reference.py index a7550f986..5912aa9d9 100644 --- a/src/sampletones_tools/codec/study/sandbox/reference.py +++ b/src/sampletones_tools/codec/study/sandbox/reference.py @@ -1,7 +1,6 @@ from dataclasses import dataclass from typing import Final, FrozenSet, Sequence, Tuple -from sampletones_player.compression.absent import is_absent from sampletones_player.compression.compressed import CompressedPlanes from sampletones_player.compression.dictionary.table import PhraseTable from sampletones_player.compression.encode import STREAM_START @@ -10,6 +9,8 @@ from sampletones_player.compression.matches.matcher import PhraseMatcher from sampletones_player.compression.options import EVERY_LAYER from sampletones_player.compression.parse.boundaries import Boundaries +from sampletones_player.compression.planes.symbols import pack_plane +from sampletones_player.specification.planes import PLANES from sampletones_tools.codec.study.corpus.song import StudySong from sampletones_tools.codec.study.sandbox.context import PlaneContext from sampletones_tools.codec.study.sandbox.costs import Costs @@ -19,6 +20,7 @@ from sampletones_tools.codec.study.sandbox.verify import verify_baseline STREAM_ENTRIES: Final[FrozenSet[int]] = frozenset({STREAM_START}) +NO_BOUNDARIES: Final[FrozenSet[int]] = frozenset() ABSENT_PARSE: Final[StudyParse] = StudyParse(tokens=(), costs=(0,)) @@ -94,7 +96,7 @@ def plane_parses( defaults: Sequence[int], ) -> Tuple[StudyParse, ...]: written = iter(parse_plane(context, grammar) for context in _contexts(cache, table, grammar.costs, defaults)) - return tuple(ABSENT_PARSE if is_absent(plane) else next(written) for plane in planes) + return tuple(next(written) if plane else ABSENT_PARSE for plane in planes) def reference( @@ -113,8 +115,11 @@ def reference( Raises: ValueError: If the baseline grammar prices a plane differently from the codec's stream. """ - planes = tuple(song.planes.planes) - cache = MatchCache(PlaneIndex.from_plane(plane) for plane in planes if not is_absent(plane)) + planes = tuple( + b"" if plane.idles(played) else pack_plane(played, plane.form, boundaries=NO_BOUNDARIES) + for plane, played in zip(PLANES, song.planes.planes, strict=True) + ) + cache = MatchCache(PlaneIndex.from_plane(plane) for plane in planes if plane) table = compressed.phrases baseline = plane_parses(planes, cache, table, BASELINE_GRAMMAR, no_defaults(len(table))) verify_baseline(baseline, compressed) diff --git a/src/sampletones_tools/codec/study/variants/packing.py b/src/sampletones_tools/codec/study/variants/packing.py deleted file mode 100644 index 91061bf7c..000000000 --- a/src/sampletones_tools/codec/study/variants/packing.py +++ /dev/null @@ -1,96 +0,0 @@ -from dataclasses import replace -from functools import partial -from typing import Final, Tuple - -from sampletones_tools.codec.study.packing.encode import encode_packed -from sampletones_tools.codec.study.packing.grammar import encode_packed_grammar -from sampletones_tools.codec.study.packing.scheme import PackingScheme -from sampletones_tools.codec.study.sandbox.grammar import BASELINE_GRAMMAR, Grammar -from sampletones_tools.codec.study.variants.sandbox import ( - DEFAULT_COUNT, - DEFAULT_COUNT_COSTS, - DEFAULT_COUNT_NOTE, - WIDE_HOLD, - WIDE_HOLD_NOTE, -) -from sampletones_tools.codec.study.variants.variant import Variant, VariantKind - -PLANE_REPEATS: Final[str] = "R1" -DRIVER_NOTE: Final[str] = ( - "a plane's byte carries a repeat count in the bits its register leaves alone; the driver " - "masks the symbol, ors the bits the hardware fixes, and counts a repeat down by subtracting " - "one step, advancing a symbol only where the count runs out" -) -FIXED_NOTE: Final[str] = f"{DRIVER_NOTE}. The masks follow from the plane, so the block states none" -FITTED_NOTE: Final[str] = ( - f"{DRIVER_NOTE}. The block states each plane's mask and the bits its register fixes, " - "two bytes a plane, and the encoder reads them off the plane's own values" -) -TABLE_NOTE: Final[str] = ( - f"{DRIVER_NOTE}, and the value is reached through a table of the plane's own values by one " - "indexed load. The block states each table, and a transposed phrase shifts a code" -) - -PACKING_VARIANTS: Final[Tuple[Variant, ...]] = ( - Variant( - name="packed-fixed", - hypothesis=PLANE_REPEATS, - kind=VariantKind.FORMAT, - note=FIXED_NOTE, - encode=partial(encode_packed, scheme=PackingScheme.FIXED), - needs_seeds=False, - ), - Variant( - name="packed-fitted", - hypothesis=PLANE_REPEATS, - kind=VariantKind.FORMAT, - note=FITTED_NOTE, - encode=partial(encode_packed, scheme=PackingScheme.FITTED), - needs_seeds=False, - ), - Variant( - name="packed-table", - hypothesis=PLANE_REPEATS, - kind=VariantKind.FORMAT, - note=TABLE_NOTE, - encode=partial(encode_packed, scheme=PackingScheme.TABLE), - needs_seeds=False, - ), -) - - -WIDE_HOLD_GRAMMAR: Final[Grammar] = replace(BASELINE_GRAMMAR, wide_hold=True) -GRADUATES_GRAMMAR: Final[Grammar] = replace( - BASELINE_GRAMMAR, - wide_hold=True, - default_counts=True, - costs=DEFAULT_COUNT_COSTS, -) -BEST_SCHEME: Final[PackingScheme] = PackingScheme.TABLE - -COMBINED_VARIANTS: Final[Tuple[Variant, ...]] = ( - Variant( - name="packed-table+wide-hold", - hypothesis=f"{PLANE_REPEATS}+{WIDE_HOLD}", - kind=VariantKind.FORMAT, - note=f"{TABLE_NOTE}. {WIDE_HOLD_NOTE}", - encode=partial(encode_packed_grammar, scheme=BEST_SCHEME, grammar=WIDE_HOLD_GRAMMAR), - needs_seeds=False, - ), - Variant( - name="packed-table+graduates", - hypothesis=f"{PLANE_REPEATS}+{WIDE_HOLD}+{DEFAULT_COUNT}", - kind=VariantKind.FORMAT, - note=f"{TABLE_NOTE}. {WIDE_HOLD_NOTE}; {DEFAULT_COUNT_NOTE}", - encode=partial(encode_packed_grammar, scheme=BEST_SCHEME, grammar=GRADUATES_GRAMMAR), - needs_seeds=False, - ), - Variant( - name="packed-fixed+graduates", - hypothesis=f"{PLANE_REPEATS}+{WIDE_HOLD}+{DEFAULT_COUNT}", - kind=VariantKind.FORMAT, - note=f"{FIXED_NOTE}. {WIDE_HOLD_NOTE}; {DEFAULT_COUNT_NOTE}", - encode=partial(encode_packed_grammar, scheme=PackingScheme.FIXED, grammar=GRADUATES_GRAMMAR), - needs_seeds=False, - ), -) diff --git a/src/sampletones_tools/codec/study/variants/registry.py b/src/sampletones_tools/codec/study/variants/registry.py index d6e4a1b2d..41c2dafcf 100644 --- a/src/sampletones_tools/codec/study/variants/registry.py +++ b/src/sampletones_tools/codec/study/variants/registry.py @@ -2,7 +2,6 @@ from sampletones_tools.codec.study.variants.baselines import Baselines, baseline_variant from sampletones_tools.codec.study.variants.layouts import LAYOUT_VARIANTS -from sampletones_tools.codec.study.variants.packing import COMBINED_VARIANTS, PACKING_VARIANTS from sampletones_tools.codec.study.variants.production import BASELINE_NAME, BUDGET_VARIANTS, SEED_VARIANTS from sampletones_tools.codec.study.variants.sandbox import grammar_variants from sampletones_tools.codec.study.variants.variant import Variant @@ -25,8 +24,6 @@ def variants(baselines: Baselines) -> Dict[str, Variant]: *BUDGET_VARIANTS, *grammar_variants(baselines), *LAYOUT_VARIANTS, - *PACKING_VARIANTS, - *COMBINED_VARIANTS, ) return {variant.name: variant for variant in every} diff --git a/src/sampletones_tools/player/assembly/include/song.inc b/src/sampletones_tools/player/assembly/include/song.inc index e5098be3e..65be41faa 100644 --- a/src/sampletones_tools/player/assembly/include/song.inc +++ b/src/sampletones_tools/player/assembly/include/song.inc @@ -34,13 +34,24 @@ PHRASE_TABLE_COUNT_SIZE = 1 PHRASE_TABLE_ENTRY_SIZE = 2 PHRASE_LENGTH_SIZE = 1 -PLANE_STATE_SIZE = 8 +COUNT_STEP = $10 +COUNT_SHIFT = 4 +PULSE_CONTROL_MASK = $CF +PULSE_CONTROL_FIXED = $30 +NOISE_CONTROL_MASK = $0F +NOISE_CONTROL_FIXED = $30 +NOISE_VALUE_MASK = $8F +NOISE_VALUE_FIXED = $00 + +PLANE_STATE_SIZE = 10 PLANE_SOURCE = 0 PLANE_PHRASE = 2 PLANE_PHRASE_TICKS = 4 PLANE_TOKEN_TICKS = 5 PLANE_VALUE = 6 PLANE_SHIFT = 7 +PLANE_REPEATS = 8 +PLANE_COUNT_MASK = 9 PLANE_STATE_BYTES = PLANE_COUNT * PLANE_STATE_SIZE diff --git a/src/sampletones_tools/player/assembly/source/channels.s b/src/sampletones_tools/player/assembly/source/channels.s index b3b3c0020..55e7107e3 100644 --- a/src/sampletones_tools/player/assembly/source/channels.s +++ b/src/sampletones_tools/player/assembly/source/channels.s @@ -35,6 +35,8 @@ timer_high_shadows: .res TRIANGLE_REGISTERS + 1 .assert ABSENT_STREAM = $FFFF, error, "an absent stream is told apart by both its bytes reading $FF" .assert BEND_FLAG = $80, error, "a value's flag is read as the sign bit" .assert PITCH_COUNT <= BEND_FLAG, error, "every pitch index fits below the flag" +.assert COUNT_STEP = 1 << COUNT_SHIFT, error, "a repeat count steps from the bit it is shifted down by" +.assert PLANE_STATE_BYTES + PLANE_PHRASE < $100, error, "a plane's phrase pointer is reached in page zero" ; Readies the tables the planes read through and points every plane at its own first token. channels_reset: @@ -71,7 +73,19 @@ channels_reset: sta pointer lda #>(song_data + STREAM_OFFSETS_OFFSET) sta pointer + 1 - jmp seed_planes + jsr seed_planes + +; States the bits each plane's register leaves for a repeat count. Every other plane keeps the +; zero seeding left it, which is what makes one symbol cover one tick. +plane_forms: + lda #(PULSE_CONTROL_MASK ^ $FF) + sta plane_state + PULSE1_CONTROL_PLANE + PLANE_COUNT_MASK + sta plane_state + PULSE2_CONTROL_PLANE + PLANE_COUNT_MASK + lda #(NOISE_CONTROL_MASK ^ $FF) + sta plane_state + NOISE_CONTROL_PLANE + PLANE_COUNT_MASK + lda #(NOISE_VALUE_MASK ^ $FF) + sta plane_state + NOISE_VALUE_PLANE + PLANE_COUNT_MASK + rts ; Brings every plane back to the token its stream is re-entered at, which is the whole of what a ; song coming round restores: the token the loop tick starts is one the plane reads outright. @@ -116,6 +130,7 @@ seed_planes: sta plane_state + PLANE_TOKEN_TICKS,x sta plane_state + PLANE_VALUE,x sta plane_state + PLANE_SHIFT,x + sta plane_state + PLANE_REPEATS,x txa clc @@ -176,6 +191,11 @@ plane_step: ; out behind a literal, or the value the plane already reached. A body played out holds its last ; value onward, which is what carries a note whose envelope has finished. plane_advance: + lda plane_state + PLANE_REPEATS,x + beq @symbol + dec plane_state + PLANE_REPEATS,x + rts +@symbol: lda plane_state + PLANE_TOKEN_TICKS,x bne @within jsr fetch_token @@ -195,6 +215,13 @@ plane_advance: bne @held inc plane_state + PLANE_PHRASE + 1,x @held: + lda plane_state + PLANE_VALUE,x + and plane_state + PLANE_COUNT_MASK,x + lsr + lsr + lsr + lsr + sta plane_state + PLANE_REPEATS,x rts ; Reads the token the plane at X stands on into that plane's own state. @@ -327,6 +354,8 @@ set_phrase: ; Writes what every plane last played to the registers its channel owns. channels_write: lda plane_state + PULSE1_CONTROL_PLANE + PLANE_VALUE + and #PULSE_CONTROL_MASK + ora #PULSE_CONTROL_FIXED sta CHANNEL_CONTROL + PULSE1_REGISTERS lda plane_state + PULSE1_BEND_PLANE + PLANE_VALUE sta bend @@ -335,6 +364,8 @@ channels_write: jsr write_timer lda plane_state + PULSE2_CONTROL_PLANE + PLANE_VALUE + and #PULSE_CONTROL_MASK + ora #PULSE_CONTROL_FIXED sta CHANNEL_CONTROL + PULSE2_REGISTERS lda plane_state + PULSE2_BEND_PLANE + PLANE_VALUE sta bend @@ -351,8 +382,12 @@ channels_write: jsr write_timer lda plane_state + NOISE_CONTROL_PLANE + PLANE_VALUE + and #NOISE_CONTROL_MASK + ora #NOISE_CONTROL_FIXED sta CHANNEL_CONTROL + NOISE_REGISTERS lda plane_state + NOISE_VALUE_PLANE + PLANE_VALUE + and #NOISE_VALUE_MASK + ora #NOISE_VALUE_FIXED sta CHANNEL_TIMER_LOW + NOISE_REGISTERS rts diff --git a/tests/integration/nsf/test_driver_bend.py b/tests/integration/nsf/test_driver_bend.py index 135a6e1bc..ecbebf015 100644 --- a/tests/integration/nsf/test_driver_bend.py +++ b/tests/integration/nsf/test_driver_bend.py @@ -9,10 +9,10 @@ from sampletones_core.timers.arithmetic import bent_timer from sampletones_player.builder import song_from_reconstruction, streams_from_instructions from sampletones_player.compression.decode import decode_planes -from sampletones_player.specification.planes import PlaneRole, plane_index from sampletones_player.compression.scheme import CompressionScheme from sampletones_player.song import Song from sampletones_player.specification.binary import BYTE_VALUES +from sampletones_player.specification.planes import PlaneRole, plane_index from sampletones_player.specification.registers import PULSE1_TIMER_HIGH, TIMER_HIGH_SHIFT from sampletones_tools.player.trace.trace import RegisterTrace from tests.integration.nsf.console.instructions import channel_values, timer_value diff --git a/tests/suite/player.py b/tests/suite/player.py index f5e5831c3..6a9d4222d 100644 --- a/tests/suite/player.py +++ b/tests/suite/player.py @@ -40,6 +40,7 @@ from sampletones_player.specification.planes import ( PLANE_COUNT, PLANES, + Plane, PlaneRole, plane_index, ) @@ -210,19 +211,31 @@ def silent_pulse() -> PulseInstruction: PLAYER_VARIED_SEED: Final[int] = 7 +def playable(plane: Plane, values: bytes) -> bytes: + """Arbitrary bytes read as values the plane can play, through its own form.""" + return bytes(plane.form.value(value) for value in values) + + def sounding_planes( control: bytes, value: bytes, bend: bytes, ) -> SongPlanes: - """A song's planes, the first pulse channel sounding what it is given and the rest resting.""" - resting = [b"" if plane.spans_flagged_ticks else bytes(len(control)) for plane in PLANES] + """A song's planes, the first pulse channel sounding what it is given and the rest resting. + + Every other plane stands at the value its own register fixes, which is where the driver seeds + it and what makes it absent. + """ + resting = [b"" if plane.spans_flagged_ticks else bytes((plane.seeded,)) * len(control) for plane in PLANES] for role, played in zip((PlaneRole.CONTROL, PlaneRole.VALUE, PlaneRole.BEND), (control, value, bend)): resting[plane_index(ChannelName.PULSE1, role)] = played return SongPlanes(planes=PlaneOrder.across(resting)) +STREAM_START: Final[int] = 0 + + def every_plane_spelling(stream: bytes) -> PlaneOrder: """One stream standing for every plane the song block writes.""" return PlaneOrder.across((stream,) * PLANE_COUNT) @@ -247,6 +260,7 @@ def spelled_song(ticks: int, nes_frequency: int) -> Song: phrases=PhraseTable(phrases=()), streams=every_plane_spelling(stream), ticks=ticks, + loop_entries=(STREAM_START,) * PLANE_COUNT, ), pitches=PLAYER_PITCHES, schedule=PlaySchedule.from_parameters(nes_frequency), diff --git a/tests/unit/sampletones_player/compression/planes/test_song.py b/tests/unit/sampletones_player/compression/planes/test_song.py index 6c8b8f9cc..cac23061d 100644 --- a/tests/unit/sampletones_player/compression/planes/test_song.py +++ b/tests/unit/sampletones_player/compression/planes/test_song.py @@ -15,6 +15,7 @@ ) from tests.suite.player import sounding_planes +TIMBRE: Final[bytes] = bytes((0x3F, 0x3A)) BEND: Final[bytes] = bytes((0x00, 0xFD)) FLAGGED: Final[bytes] = bytes((flagged_value(3, True), flagged_value(4, True))) PULSE1_BEND: Final[int] = plane_index(ChannelName.PULSE1, PlaneRole.BEND) @@ -25,24 +26,26 @@ class TestASongGathersEveryPlaneItsBlockWrites: """Every plane advances beside the rest, so the song states them all under one length.""" def test_a_song_carries_the_planes_a_block_writes(self) -> None: - assert len(sounding_planes(bytes((1, 2)), FLAGGED, BEND).planes) == PLANE_COUNT + assert len(sounding_planes(TIMBRE, FLAGGED, BEND).planes) == PLANE_COUNT def test_a_plane_is_reached_by_the_name_the_block_writes_it_under(self) -> None: - planes = sounding_planes(bytes((1, 2)), FLAGGED, BEND).planes + planes = sounding_planes(TIMBRE, FLAGGED, BEND).planes assert planes.pulse1_bend == BEND def test_a_channel_answers_with_the_planes_it_writes(self) -> None: - planes = sounding_planes(bytes((1, 2)), FLAGGED, BEND) + planes = sounding_planes(TIMBRE, FLAGGED, BEND) - assert planes.of(ChannelName.PULSE1) == (bytes((1, 2)), FLAGGED, BEND) + assert planes.of(ChannelName.PULSE1) == (TIMBRE, FLAGGED, BEND) def test_a_song_lasts_the_ticks_its_planes_cover(self) -> None: - assert sounding_planes(bytes((1, 2)), FLAGGED, BEND).ticks == 2 + assert sounding_planes(TIMBRE, FLAGGED, BEND).ticks == 2 def test_planes_covering_different_ticks_are_refused(self) -> None: with pytest.raises(ValidationError): - SongPlanes(planes=PlaneOrder.across(bytes(plane) for plane in range(1, PLANE_COUNT + 1))) + SongPlanes( + planes=PlaneOrder.across(bytes((plane.seeded,)) * index for index, plane in enumerate(PLANES, start=1)) + ) def test_planes_covering_no_tick_are_refused(self) -> None: with pytest.raises(ValidationError): @@ -55,19 +58,19 @@ class TestABendPlaneCoversTheFlaggedTicks: def test_a_bend_for_every_flagged_tick_is_taken(self) -> None: value = bytes((flagged_value(3, False), flagged_value(3, True))) - assert sounding_planes(bytes(2), value, BEND[:1]).planes.pulse1_bend == BEND[:1] + assert sounding_planes(TIMBRE, value, BEND[:1]).planes.pulse1_bend == BEND[:1] def test_a_bend_plane_holding_more_than_the_flags_ask_for_is_refused(self) -> None: with pytest.raises(ValidationError): - sounding_planes(bytes(2), bytes((3, 4)), BEND) + sounding_planes(TIMBRE, bytes((3, 4)), BEND) def test_a_bend_plane_holding_fewer_than_the_flags_ask_for_is_refused(self) -> None: with pytest.raises(ValidationError): - sounding_planes(bytes(2), FLAGGED, BEND[:1]) + sounding_planes(TIMBRE, FLAGGED, BEND[:1]) def test_a_song_returning_to_a_tick_re_enters_the_bend_plane_past_the_flags_before_it(self) -> None: value = bytes((flagged_value(3, True), flagged_value(3, False), flagged_value(3, True))) - planes = sounding_planes(bytes(3), value, BEND) + planes = sounding_planes(TIMBRE + bytes((0x30,)), value, BEND) assert [planes.positions(tick)[PULSE1_BEND] for tick in range(4)] == [0, 1, 1, 2] dense = [position for name, position in zip(PlaneOrder.names(), planes.positions(2)) if name not in BENDS] diff --git a/tests/unit/sampletones_player/compression/test_decode.py b/tests/unit/sampletones_player/compression/test_decode.py index 5ecb01256..9875ed8fc 100644 --- a/tests/unit/sampletones_player/compression/test_decode.py +++ b/tests/unit/sampletones_player/compression/test_decode.py @@ -1,11 +1,14 @@ from typing import Final +from sampletones_core.constants.enums import ChannelName from sampletones_player.compression.decode import decode_plane from sampletones_player.compression.dictionary.phrase import Phrase from sampletones_player.compression.dictionary.table import phrase_table from sampletones_player.specification.compression import PHRASE_ID_ESCAPE, TokenTag +from sampletones_player.specification.planes import PLANES, Plane, PlaneRole, plane_index MOTIF: Final[bytes] = bytes((40, 44, 47)) +SPELLED_PLANE: Final[Plane] = PLANES[plane_index(ChannelName.PULSE1, PlaneRole.VALUE)] DICTIONARY = phrase_table((Phrase(body=MOTIF),)) @@ -14,40 +17,40 @@ class TestWhatTheDriverReadsFromAStream: def test_a_literal_writes_the_values_that_follow_it(self) -> None: stream = bytes((TokenTag.LITERAL | 2, 5, 6, 7)) - assert decode_plane(stream, DICTIONARY, 3) == bytes((5, 6, 7)) + assert decode_plane(stream, DICTIONARY, SPELLED_PLANE, 3) == bytes((5, 6, 7)) def test_a_hold_writes_the_value_the_plane_reached(self) -> None: stream = bytes((TokenTag.LITERAL | 0, 9, TokenTag.HOLD | 2)) - assert decode_plane(stream, DICTIONARY, 4) == bytes((9, 9, 9, 9)) + assert decode_plane(stream, DICTIONARY, SPELLED_PLANE, 4) == bytes((9, 9, 9, 9)) def test_a_phrase_writes_the_body_the_table_holds(self) -> None: stream = bytes((TokenTag.PHRASE | 0, len(MOTIF) - 1)) - assert decode_plane(stream, DICTIONARY, len(MOTIF)) == MOTIF + assert decode_plane(stream, DICTIONARY, SPELLED_PLANE, len(MOTIF)) == MOTIF def test_a_phrase_running_past_its_body_holds_the_value_it_ended_on(self) -> None: stream = bytes((TokenTag.PHRASE | 0, len(MOTIF) + 1)) - assert decode_plane(stream, DICTIONARY, len(MOTIF) + 2) == MOTIF + bytes((MOTIF[-1],)) * 2 + assert decode_plane(stream, DICTIONARY, SPELLED_PLANE, len(MOTIF) + 2) == MOTIF + bytes((MOTIF[-1],)) * 2 def test_a_phrase_cut_short_writes_as_much_of_the_body_as_sounded(self) -> None: stream = bytes((TokenTag.PHRASE | 0, 1)) - assert decode_plane(stream, DICTIONARY, 2) == MOTIF[:2] + assert decode_plane(stream, DICTIONARY, SPELLED_PLANE, 2) == MOTIF[:2] def test_a_shifted_phrase_writes_the_body_moved_by_the_shift(self) -> None: stream = bytes((TokenTag.TRANSPOSED_PHRASE | 0, len(MOTIF) - 1, 5)) - assert decode_plane(stream, DICTIONARY, len(MOTIF)) == bytes(value + 5 for value in MOTIF) + assert decode_plane(stream, DICTIONARY, SPELLED_PLANE, len(MOTIF)) == bytes(value + 5 for value in MOTIF) def test_a_shift_walks_the_byte_around_where_it_reaches_past_one(self) -> None: """The driver adds the shift to a byte, so a fall reaches it as the byte that wraps to it.""" stream = bytes((TokenTag.TRANSPOSED_PHRASE | 0, 0, 0xFD)) - assert decode_plane(stream, DICTIONARY, 1) == bytes((MOTIF[0] - 3,)) + assert decode_plane(stream, DICTIONARY, SPELLED_PLANE, 1) == bytes((MOTIF[0] - 3,)) def test_an_escaped_phrase_names_its_id_in_the_byte_that_follows(self) -> None: table = phrase_table( tuple(Phrase(body=bytes((value, value))) for value in range(PHRASE_ID_ESCAPE)) + (Phrase(body=MOTIF),) ) stream = bytes((TokenTag.PHRASE | PHRASE_ID_ESCAPE, PHRASE_ID_ESCAPE, len(MOTIF) - 1)) - assert decode_plane(stream, table, len(MOTIF)) == MOTIF + assert decode_plane(stream, table, SPELLED_PLANE, len(MOTIF)) == MOTIF def test_a_token_reaching_past_the_song_stops_where_the_song_does(self) -> None: stream = bytes((TokenTag.LITERAL | 0, 4, TokenTag.HOLD | 63)) - assert decode_plane(stream, DICTIONARY, 3) == bytes((4, 4, 4)) + assert decode_plane(stream, DICTIONARY, SPELLED_PLANE, 3) == bytes((4, 4, 4)) diff --git a/tests/unit/sampletones_player/compression/test_seeds.py b/tests/unit/sampletones_player/compression/test_seeds.py index 7920316db..2fdb4e220 100644 --- a/tests/unit/sampletones_player/compression/test_seeds.py +++ b/tests/unit/sampletones_player/compression/test_seeds.py @@ -1,4 +1,4 @@ -from typing import Final, Tuple +from typing import Final, FrozenSet, Tuple import pytest @@ -10,9 +10,11 @@ from sampletones_core.timers.utils import get_timer_table from sampletones_player.compression.pitch import PitchTable from sampletones_player.compression.planes.separate import channel_planes +from sampletones_player.compression.planes.symbols import pack_plane from sampletones_player.compression.seeds import phrases_from_project from sampletones_player.registers.channel import channel_registers from sampletones_player.specification.binary import unsigned_byte +from sampletones_player.specification.planes import PLANES from sampletones_shared.music import Tuning from tests.suite.performance import make_pulse_reconstruction, project_with_instrument, project_with_sample from tests.suite.player import PLAYER_FULL_VOLUME, PLAYER_REFERENCE_PITCH @@ -47,6 +49,9 @@ def _offered(project: Project) -> Tuple[bytes, ...]: return tuple(phrase.body for phrase in phrases_from_project(project, TUNING, ALL_CHANNELS)) +NO_BOUNDARIES: Final[FrozenSet[int]] = frozenset() + + class TestTheInstrumentsSeedTheDictionary: """A song plays sample slices at rows, so the shapes its planes repeat are the slices.""" @@ -55,7 +60,11 @@ def test_the_phrases_are_the_planes_the_slice_turns_over( project: Project, slice_planes: Tuple[bytes, ...], ) -> None: - turning = tuple(plane for plane in slice_planes if len(set(plane)) > 1) + turning = tuple( + pack_plane(plane, PLANES[index].form, boundaries=NO_BOUNDARIES) + for index, plane in enumerate(slice_planes) + if len(set(plane)) > 1 + ) assert _offered(project) == turning def test_a_plane_holding_one_value_offers_the_dictionary_nothing( diff --git a/tests/unit/sampletones_player/nsf/test_layout.py b/tests/unit/sampletones_player/nsf/test_layout.py index 808d0d129..27f6c0f15 100644 --- a/tests/unit/sampletones_player/nsf/test_layout.py +++ b/tests/unit/sampletones_player/nsf/test_layout.py @@ -99,7 +99,7 @@ def test_every_plane_states_an_entry(self) -> None: def test_an_entry_stands_at_the_token_the_loop_tick_starts(self) -> None: song = figure_song(LOOP_TICK) layout = SongLayout.of(song) - entered = song.planes.entries(decode_planes(song.planes).positions(LOOP_TICK)) + entered = song.planes.loop_entries assert layout.loop_entries == tuple( ABSENT_STREAM if entry is None else offset + entry for offset, entry in zip(layout.streams, entered) ) diff --git a/tests/unit/sampletones_player/nsf/test_song.py b/tests/unit/sampletones_player/nsf/test_song.py index 6338fde18..9b9c2c9cc 100644 --- a/tests/unit/sampletones_player/nsf/test_song.py +++ b/tests/unit/sampletones_player/nsf/test_song.py @@ -87,9 +87,11 @@ class TestSongBytes: The layout is the contract the driver reads the song through, so the literal states it in full: the header, the timer every pitch sounds at, the dictionary the tokens name, and one - token stream per plane the block holds. The song bends nowhere, so each bend plane is absent - and its header entries carry the sentinel. The timer table is named rather than transcribed, - since it is the tuning's own table and the block carries whatever that table holds. + token stream per plane the block holds. The song bends nowhere, so each bend plane is absent; + its second pulse channel and its noise channel rest throughout, so both control planes stand + at the value their register fixes and are absent too. Each absent plane's header entries carry + the sentinel. The timer table is named rather than transcribed, since it is the tuning's own + table and the block carries whatever that table holds. """ EXPECTED_HEADER: Final[bytes] = ( @@ -100,25 +102,17 @@ class TestSongBytes: b"\x37\x00" b"\x07\x01" b"\x08\x01\x0b\x01\xff\xff" - b"\x0e\x01\x11\x01\xff\xff" - b"\x14\x01\x17\x01\xff\xff" - b"\x1a\x01\x1d\x01" + b"\xff\xff\x0e\x01\xff\xff" + b"\x11\x01\x14\x01\xff\xff" + b"\xff\xff\x17\x01" b"\x08\x01\x0b\x01\xff\xff" - b"\x0e\x01\x11\x01\xff\xff" - b"\x14\x01\x17\x01\xff\xff" - b"\x1a\x01\x1d\x01" + b"\xff\xff\x0e\x01\xff\xff" + b"\x11\x01\x14\x01\xff\xff" + b"\xff\xff\x17\x01" ) EXPECTED_STREAMS: Final[bytes] = ( - b"\x00" - b"\x41\x3f\x30" - b"\x40\x21\x00" - b"\x40\x30\x00" - b"\x40\x21\x00" - b"\x40\x80\x00" - b"\x40\x21\x00" - b"\x40\x30\x00" - b"\x40\x0a\x00" + b"\x00" b"\x41\x0f\x00" b"\x40\x21\x00" b"\x40\x21\x00" b"\x40\x80\x00" b"\x40\x21\x00" b"\x40\x1a" ) def test_the_song_serializes_to_the_expected_bytes(self) -> None: @@ -256,7 +250,7 @@ class TestLoopEntries: def test_a_song_that_repeats_states_the_token_its_loop_tick_starts(self) -> None: song = repeating_song() data = song_to_bytes(song, PROGRAM_AREA_BYTES) - entered = song.planes.entries(decode_planes(song.planes).positions(LOOP_TICK)) + entered = song.planes.loop_entries assert loop_entries(data) == tuple( ABSENT_STREAM if entry is None else offset + entry for offset, entry in zip(stream_offsets(data), entered) ) diff --git a/tests/unit/sampletones_tools/codec/study/test_layouts.py b/tests/unit/sampletones_tools/codec/study/test_layouts.py index 699bc3e72..57ac13a04 100644 --- a/tests/unit/sampletones_tools/codec/study/test_layouts.py +++ b/tests/unit/sampletones_tools/codec/study/test_layouts.py @@ -10,13 +10,13 @@ from sampletones_core.project.voices.instrument import Instrument from sampletones_core.timers.utils import get_timer_table from sampletones_player.builder import streams_from_instructions -from sampletones_player.compression.absent import is_absent from sampletones_player.compression.pitch import PitchTable from sampletones_player.compression.planes.separate import channel_planes, planes_from_streams from sampletones_player.compression.seeds import phrases_from_project from sampletones_player.registers.channel import channel_registers from sampletones_player.specification.binary import unsigned_byte from sampletones_player.specification.compression import BEND_FLAG +from sampletones_player.specification.planes import PLANES from sampletones_shared.music import Tuning from sampletones_tools.codec.study.corpus.notes import channel_notes, song_notes from sampletones_tools.codec.study.corpus.slices import project_slices @@ -192,6 +192,7 @@ def test_an_absent_plane_costs_no_stream(self, layout: PlaneLayout) -> None: song = study_song(bent_frames(PLAYER_REFERENCE_PITCH, (0, 3, 0))) planes = layout_planes(song, layout) streams = encode_layout(song, layout).streams - assert any(is_absent(plane) for plane in planes) - assert all(size == 0 for plane, size in zip(planes, streams) if is_absent(plane)) - assert all(size > 0 for plane, size in zip(planes, streams) if not is_absent(plane)) + idle = tuple(plane.idles(played) for plane, played in zip(PLANES, planes, strict=True)) + assert any(idle) + assert all(size == 0 for resting, size in zip(idle, streams) if resting) + assert all(size > 0 for resting, size in zip(idle, streams) if not resting) diff --git a/tests/unit/sampletones_tools/codec/study/test_packing.py b/tests/unit/sampletones_tools/codec/study/test_packing.py deleted file mode 100644 index c61b81d58..000000000 --- a/tests/unit/sampletones_tools/codec/study/test_packing.py +++ /dev/null @@ -1,300 +0,0 @@ -from math import ceil -from typing import Final, FrozenSet, Tuple - -import pytest - -from sampletones_core.constants.general import MAX_VOLUME -from sampletones_player.compression.planes.order import PlaneOrder -from sampletones_player.specification.binary import MAX_BYTE_VALUE -from sampletones_player.specification.registers import SUSTAINED_LEVEL -from sampletones_tools.codec.study.packing.fitted import FORM_SIZE, fitted_form -from sampletones_tools.codec.study.packing.form import NO_BITS, WHOLE_BYTE, PlaneForm -from sampletones_tools.codec.study.packing.registers import ( - NOISE_CONTROL_FORM, - NOISE_VALUE_FORM, - PULSE_CONTROL_FORM, - register_forms, -) -from sampletones_tools.codec.study.packing.scheme import ( - PackingScheme, - plane_codings, - stated_bytes, -) -from sampletones_tools.codec.study.packing.symbols import ( - pack_plane, - symbol_boundaries, - unpack_plane, -) -from sampletones_tools.codec.study.packing.table import ( - TABLE_LENGTH_SIZE, - TABLE_OFFSET_SIZE, - TableForm, -) - -NO_BOUNDARIES: Final[FrozenSet[int]] = frozenset() -QUIET: Final[int] = SUSTAINED_LEVEL -LOUD: Final[int] = SUSTAINED_LEVEL | MAX_VOLUME -LONG_REST: Final[int] = 200 - - -def _noise_control(*volumes: int) -> bytes: - return bytes(SUSTAINED_LEVEL | volume for volume in volumes) - - -class TestHowAPlanesByteDivides: - def test_a_pulse_control_byte_counts_in_the_bits_the_hardware_fixes(self) -> None: - assert PULSE_CONTROL_FORM.count_mask == SUSTAINED_LEVEL - assert PULSE_CONTROL_FORM.repeats == 4 - - def test_a_noise_control_byte_counts_in_its_whole_top_nibble(self) -> None: - assert NOISE_CONTROL_FORM.repeats == MAX_VOLUME + 1 - - def test_a_noise_period_byte_counts_under_its_mode_bit(self) -> None: - assert NOISE_VALUE_FORM.repeats == 8 - - def test_a_plane_spending_its_whole_byte_counts_nothing(self) -> None: - assert not WHOLE_BYTE.counts - assert WHOLE_BYTE.repeats == 1 - - def test_the_song_block_names_a_form_for_every_plane(self) -> None: - assert len(register_forms()) == len(PlaneOrder.names()) - - def test_the_value_a_symbol_plays_carries_the_bits_the_register_fixes(self) -> None: - symbol = NOISE_CONTROL_FORM.symbol(LOUD, repeats=NOISE_CONTROL_FORM.repeats) - - assert NOISE_CONTROL_FORM.value(symbol) == LOUD - assert NOISE_CONTROL_FORM.repeated(symbol) == NOISE_CONTROL_FORM.repeats - - def test_a_control_plane_is_seeded_to_a_sustained_silence(self) -> None: - assert PULSE_CONTROL_FORM.seeded == SUSTAINED_LEVEL - assert NOISE_CONTROL_FORM.seeded == SUSTAINED_LEVEL - - def test_a_plane_spending_its_whole_byte_is_seeded_to_nothing(self) -> None: - assert WHOLE_BYTE.seeded == NO_BITS - - def test_a_channel_standing_by_leaves_its_control_plane_idle(self) -> None: - assert PULSE_CONTROL_FORM.idles(_noise_control(*([0] * 8))) - - def test_a_channel_that_sounds_leaves_its_control_plane_standing(self) -> None: - assert not PULSE_CONTROL_FORM.idles(_noise_control(0, 0, MAX_VOLUME, 0)) - - def test_a_form_whose_count_bits_meet_its_value_is_refused(self) -> None: - with pytest.raises(ValueError): - PlaneForm(value_mask=0x0F, value_or=0x01) - - def test_a_form_whose_count_bits_lie_apart_is_refused(self) -> None: - with pytest.raises(ValueError): - PlaneForm(value_mask=0x7E, value_or=NO_BITS) - - def test_a_value_carrying_other_fixed_bits_is_refused(self) -> None: - with pytest.raises(ValueError): - NOISE_CONTROL_FORM.symbol(MAX_VOLUME, repeats=1) - - def test_a_count_past_what_one_symbol_holds_is_refused(self) -> None: - with pytest.raises(ValueError): - PULSE_CONTROL_FORM.symbol(QUIET, repeats=PULSE_CONTROL_FORM.repeats + 1) - - -class TestWhatAPackedPlanePlays: - def test_the_ticks_come_back_as_they_were_written(self) -> None: - plane = _noise_control(0, 0, 0, 5, 5, 1, 1, 1, 1, 1, 1, 0) - - packed = pack_plane(plane, NOISE_CONTROL_FORM, boundaries=NO_BOUNDARIES) - - assert unpack_plane(packed, NOISE_CONTROL_FORM) == plane - - def test_a_run_of_one_value_costs_one_symbol(self) -> None: - plane = _noise_control(*([MAX_VOLUME] * NOISE_CONTROL_FORM.repeats)) - - assert len(pack_plane(plane, NOISE_CONTROL_FORM, boundaries=NO_BOUNDARIES)) == 1 - - def test_a_run_past_what_one_symbol_counts_takes_as_many_as_it_needs(self) -> None: - ticks = NOISE_CONTROL_FORM.repeats * 3 + 1 - plane = _noise_control(*([MAX_VOLUME] * ticks)) - - packed = pack_plane(plane, NOISE_CONTROL_FORM, boundaries=NO_BOUNDARIES) - - assert len(packed) == 4 - assert unpack_plane(packed, NOISE_CONTROL_FORM) == plane - - def test_a_long_rest_falls_to_a_fraction_of_its_ticks(self) -> None: - plane = _noise_control(*([0] * LONG_REST)) - - packed = pack_plane(plane, NOISE_CONTROL_FORM, boundaries=NO_BOUNDARIES) - - assert len(packed) == ceil(LONG_REST / NOISE_CONTROL_FORM.repeats) - - def test_a_plane_spending_its_whole_byte_packs_to_itself(self) -> None: - plane = bytes(range(64)) * 2 - - assert pack_plane(plane, WHOLE_BYTE, boundaries=NO_BOUNDARIES) == plane - - def test_every_byte_a_symbol_may_take_plays_back(self) -> None: - symbols = bytes(range(MAX_BYTE_VALUE + 1)) - - played = unpack_plane(symbols, NOISE_VALUE_FORM) - - assert len(played) == sum(NOISE_VALUE_FORM.repeated(symbol) for symbol in symbols) - assert set(played) <= {NOISE_VALUE_FORM.value(symbol) for symbol in symbols} - - def test_a_plane_of_no_ticks_packs_to_no_symbols(self) -> None: - assert pack_plane(b"", NOISE_CONTROL_FORM, boundaries=NO_BOUNDARIES) == b"" - - -class TestWhereATokenStartsOnAPackedPlane: - def test_a_boundary_begins_a_symbol_of_its_own(self) -> None: - plane = _noise_control(*([MAX_VOLUME] * 8)) - boundaries: FrozenSet[int] = frozenset({3}) - - packed = pack_plane(plane, NOISE_CONTROL_FORM, boundaries=boundaries) - - assert unpack_plane(packed, NOISE_CONTROL_FORM) == plane - assert NOISE_CONTROL_FORM.repeated(packed[0]) == 3 - - def test_the_boundary_reaches_the_symbol_the_tick_begins(self) -> None: - plane = _noise_control(*([MAX_VOLUME] * 8)) - boundaries: FrozenSet[int] = frozenset({3}) - - assert symbol_boundaries(plane, NOISE_CONTROL_FORM, boundaries=boundaries) == frozenset({1}) - - def test_a_plane_spending_its_whole_byte_keeps_its_boundaries(self) -> None: - plane = bytes(range(16)) - boundaries: FrozenSet[int] = frozenset({4, 9}) - - assert symbol_boundaries(plane, WHOLE_BYTE, boundaries=boundaries) == boundaries - - def test_several_boundaries_each_begin_a_symbol(self) -> None: - plane = _noise_control(*([0] * 40)) - boundaries: FrozenSet[int] = frozenset({5, 17, 33}) - - packed = pack_plane(plane, NOISE_CONTROL_FORM, boundaries=boundaries) - starts = symbol_boundaries(plane, NOISE_CONTROL_FORM, boundaries=boundaries) - - assert unpack_plane(packed, NOISE_CONTROL_FORM) == plane - assert len(starts) == len(boundaries) - assert _ticks_before(packed, starts) == {5, 17, 33} - - -def _ticks_before(packed: bytes, starts: FrozenSet[int]) -> FrozenSet[int]: - """The tick each named symbol begins on, read by playing the symbols before it.""" - reached: Tuple[int, ...] = tuple( - sum(NOISE_CONTROL_FORM.repeated(symbol) for symbol in packed[:start]) for start in sorted(starts) - ) - return frozenset(reached) - - -class TestTheNarrowestFormAPlaneAllows: - def test_a_plane_naming_a_control_bit_in_every_tick_frees_it(self) -> None: - plane = bytes([0x80, 0xFF, 0x80, 0xFF]) - - form = fitted_form(plane) - - assert form.count_mask == 0x80 - assert form.repeats == 2 - assert form.seeded == 0x80 - - def test_a_plane_reaching_only_low_values_frees_the_bits_above_them(self) -> None: - plane = bytes([0x00, 0x03, 0x1B, 0x07]) - - form = fitted_form(plane) - - assert form.repeats == 8 - assert form.fits(plane) - - def test_a_plane_turning_over_every_bit_takes_its_whole_byte(self) -> None: - plane = bytes(range(MAX_BYTE_VALUE + 1)) - - assert fitted_form(plane) == WHOLE_BYTE - - def test_a_plane_of_one_value_frees_its_whole_byte(self) -> None: - plane = bytes([SUSTAINED_LEVEL] * 4) - - form = fitted_form(plane) - - assert form.seeded == SUSTAINED_LEVEL - assert form.idles(plane) - - def test_a_plane_of_no_ticks_takes_its_whole_byte(self) -> None: - assert fitted_form(b"") == WHOLE_BYTE - - def test_the_fitted_form_reaches_at_least_as_far_as_the_register_fixes(self) -> None: - plane = _noise_control(0, 1, 2, MAX_VOLUME) - - assert fitted_form(plane).repeats >= NOISE_CONTROL_FORM.repeats - - -class TestAPlanesValuesGatheredIntoATable: - def test_a_code_stands_for_the_value_it_names(self) -> None: - plane = bytes([0x80, 0xFF, 0x80]) - - table = TableForm.across(plane) - - assert table.values == (0x80, 0xFF) - assert table.value(table.symbol(0xFF, repeats=1)) == 0xFF - - def test_two_values_leave_seven_bits_to_count_with(self) -> None: - table = TableForm.across(bytes([0x80, 0xFF])) - - assert table.code_bits == 1 - assert table.repeats == 128 - - def test_the_table_leads_with_the_value_the_plane_is_seeded_to(self) -> None: - table = TableForm.across(bytes([0xFF, 0x80, 0xFF])) - - assert table.seeded == 0x80 - - def test_the_ticks_come_back_as_they_were_written(self) -> None: - plane = bytes([0x80] * 300 + [0xFF] * 5 + [0x80]) - - packed = pack_plane(plane, TableForm.across(plane), boundaries=NO_BOUNDARIES) - - assert unpack_plane(packed, TableForm.across(plane)) == plane - - def test_the_block_states_the_table_it_reads_through(self) -> None: - table = TableForm.across(bytes(range(16))) - - assert table.stated == TABLE_OFFSET_SIZE + TABLE_LENGTH_SIZE + 16 - - def test_a_table_naming_a_value_twice_is_refused(self) -> None: - with pytest.raises(ValueError): - TableForm(values=(1, 1)) - - def test_a_table_naming_no_value_is_refused(self) -> None: - with pytest.raises(ValueError): - TableForm(values=()) - - -class TestWhatASchemeStates: - def test_the_register_scheme_states_nothing(self) -> None: - planes = (_noise_control(0, 1),) * len(PlaneOrder.names()) - - codings = plane_codings(planes, PackingScheme.FIXED) - - assert stated_bytes(codings, PackingScheme.FIXED) == 0 - - def test_the_fitted_scheme_states_a_form_per_plane(self) -> None: - planes = (_noise_control(0, 1),) * len(PlaneOrder.names()) - - codings = plane_codings(planes, PackingScheme.FITTED) - - assert stated_bytes(codings, PackingScheme.FITTED) == FORM_SIZE * len(planes) - - def test_the_table_scheme_states_every_table(self) -> None: - planes = (bytes([0x80, 0xFF]),) * len(PlaneOrder.names()) - - codings = plane_codings(planes, PackingScheme.TABLE) - - assert stated_bytes(codings, PackingScheme.TABLE) == sum(coding.stated for coding in codings) - - def test_every_scheme_reads_back_the_ticks_it_packs(self) -> None: - planes = ( - _noise_control(0, 0, 3, 3, 3, MAX_VOLUME), - bytes([0x80, 0x80, 0xFF, 0x80]), - bytes(range(8)), - ) - for scheme in PackingScheme: - codings = plane_codings(planes, scheme)[: len(planes)] - for plane, coding in zip(planes, codings): - packed = pack_plane(plane, coding, boundaries=NO_BOUNDARIES) - - assert unpack_plane(packed, coding) == plane diff --git a/tests/unit/sampletones_tools/codec/study/test_sandbox.py b/tests/unit/sampletones_tools/codec/study/test_sandbox.py index f27d4f555..de8b0c517 100644 --- a/tests/unit/sampletones_tools/codec/study/test_sandbox.py +++ b/tests/unit/sampletones_tools/codec/study/test_sandbox.py @@ -21,7 +21,7 @@ CHEAP_PHRASE_IDS, MAX_HOLD_TICKS, ) -from sampletones_player.specification.planes import PLANE_COUNT +from sampletones_player.specification.planes import PLANE_COUNT, PLANES, PlaneRole from sampletones_shared.music import Tuning from sampletones_tools.codec.study.corpus.song import SongGroup, StudySong from sampletones_tools.codec.study.sandbox.context import PlaneContext @@ -39,6 +39,7 @@ from sampletones_tools.codec.study.variants.sandbox import DEFAULT_COUNT_COSTS, GRAMMAR_VARIANTS, GrammarVariant from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase +from tests.suite.player import playable from tests.suite.study import NO_STUDY_SLICES, lowest_notes ENTRIES: Final[FrozenSet[int]] = frozenset({STREAM_START}) @@ -83,13 +84,13 @@ def _dense_plane(random: Random, ticks: int) -> bytes: def _song(ticks: int) -> StudySong: random = Random(SEED) planes: List[bytes] = [] - for plane in range(PLANE_COUNT): - if plane % 3 == 2: + for plane in PLANES: + if plane.spans_flagged_ticks: planes.append(b"") - elif plane % 3 == 0: - planes.append(_runs_plane(random, ticks)) + elif plane.role is PlaneRole.CONTROL: + planes.append(playable(plane, _runs_plane(random, ticks))) else: - planes.append(_figures_plane(random, ticks)) + planes.append(playable(plane, _figures_plane(random, ticks))) return StudySong( name="song", diff --git a/tests/unit/sampletones_tools/codec/study/test_variants.py b/tests/unit/sampletones_tools/codec/study/test_variants.py index f6e71ddc1..2fd94787a 100644 --- a/tests/unit/sampletones_tools/codec/study/test_variants.py +++ b/tests/unit/sampletones_tools/codec/study/test_variants.py @@ -120,6 +120,7 @@ def _measurement( compressed = CompressedPlanes( phrases=phrase_table(()), streams=PlaneOrder.across([stream] * PLANE_COUNT), + loop_entries=(0,) * PLANE_COUNT, ticks=TICKS, ) return Measurement( diff --git a/tests/unit/sampletones_tools/player/test_song_include.py b/tests/unit/sampletones_tools/player/test_song_include.py index 3e631e64e..17c8e72ac 100644 --- a/tests/unit/sampletones_tools/player/test_song_include.py +++ b/tests/unit/sampletones_tools/player/test_song_include.py @@ -20,7 +20,13 @@ TOKEN_TAG_MASK, TokenTag, ) -from sampletones_player.specification.planes import PLANE_COUNT +from sampletones_player.specification.planes import ( + COUNT_STEP, + NOISE_CONTROL_FORM, + NOISE_VALUE_FORM, + PLANE_COUNT, + PULSE_CONTROL_FORM, +) from sampletones_player.specification.song import ( ABSENT_STREAM, LOOP_ENTRIES_OFFSET, @@ -69,6 +75,13 @@ "PHRASE_LENGTH_SIZE": PHRASE_LENGTH_SIZE, "PLANE_STATE_SIZE": PLANE_STATE_SIZE, "PLANE_STATE_BYTES": PLANE_COUNT * PLANE_STATE_SIZE, + "COUNT_STEP": COUNT_STEP, + "PULSE_CONTROL_MASK": PULSE_CONTROL_FORM.value_mask, + "PULSE_CONTROL_FIXED": PULSE_CONTROL_FORM.value_or, + "NOISE_CONTROL_MASK": NOISE_CONTROL_FORM.value_mask, + "NOISE_CONTROL_FIXED": NOISE_CONTROL_FORM.value_or, + "NOISE_VALUE_MASK": NOISE_VALUE_FORM.value_mask, + "NOISE_VALUE_FIXED": NOISE_VALUE_FORM.value_or, } From d1aaf7181a481afc80709164399e0c449fa33a28 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 21 Sep 2026 13:45:30 +0200 Subject: [PATCH 05/11] Folded: the triangle's silence into the pitch it sounds --- docs/development/bugs-and-todos.md | 4 ++ .../compression/planes/order.py | 4 +- .../compression/planes/rebuild.py | 56 ++++++++++++++++-- .../compression/planes/separate.py | 41 +++++++++++-- .../driver/binary/driver.bin | Bin 760 -> 774 bytes src/sampletones_player/registers/triangle.py | 17 ++++-- .../specification/planes.py | 10 +++- .../codec/study/layouts/tone.py | 31 ++++++---- .../player/assembly/include/song.inc | 15 +++-- .../player/assembly/source/channels.s | 53 +++++++++++------ tests/integration/tooling/test_codec_study.py | 8 +-- tests/suite/player.py | 16 +++-- .../unit/sampletones_player/nsf/test_song.py | 31 +++++----- .../specification/test_planes.py | 19 +++++- .../codec/study/test_accounting.py | 7 ++- .../codec/study/test_sandbox.py | 3 +- .../player/test_song_include.py | 10 ++++ 17 files changed, 242 insertions(+), 83 deletions(-) diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 4ad8d023e..f3ed35e35 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -82,6 +82,10 @@ starts carrying. ### Technical +* Re-measuring what the codec achieves. The ladder in `docs/concepts/compression.md` §6, its + prose figures and the Appendix are the reading taken before a plane counted its own repeats + and before the triangle named its silence; `uv run sampletones codec report` rewrites them + once the format settles. * Leading the calibration report with `mr-loudness-dB`. `build_referees` puts `mr-auditory-dB` first, which reads silence as closer to a tone than any render, until by-ear ratings of a sweep hold the loudness-weighted referee at ρ ≥ 0.6 in every category. A listening round in September diff --git a/src/sampletones_player/compression/planes/order.py b/src/sampletones_player/compression/planes/order.py index a15c79fb3..35133463a 100644 --- a/src/sampletones_player/compression/planes/order.py +++ b/src/sampletones_player/compression/planes/order.py @@ -23,8 +23,7 @@ class PlaneOrder(NamedTuple): pulse2_control: The second pulse channel's timbre and volume. pulse2_value: The second pulse channel's pitch. pulse2_bend: The second pulse channel's divider offset. - triangle_control: The triangle channel's linear counter. - triangle_value: The triangle channel's pitch. + triangle_value: The triangle channel's pitch, the index above the table naming a rest. triangle_bend: The triangle channel's divider offset. noise_control: The noise channel's timbre and volume. noise_value: The noise channel's period. @@ -36,7 +35,6 @@ class PlaneOrder(NamedTuple): pulse2_control: bytes pulse2_value: bytes pulse2_bend: bytes - triangle_control: bytes triangle_value: bytes triangle_bend: bytes noise_control: bytes diff --git a/src/sampletones_player/compression/planes/rebuild.py b/src/sampletones_player/compression/planes/rebuild.py index 0de1f56e3..9d78c1680 100644 --- a/src/sampletones_player/compression/planes/rebuild.py +++ b/src/sampletones_player/compression/planes/rebuild.py @@ -1,4 +1,4 @@ -from typing import Iterator, Tuple +from typing import Final, Iterator, List, Tuple from sampletones_core.constants.enums import ChannelName from sampletones_player.compression.pitch import PitchTable @@ -9,11 +9,17 @@ from sampletones_player.registers.streams import ChannelStreams from sampletones_player.registers.triangle import TriangleRegisters from sampletones_player.specification.binary import signed_byte +from sampletones_player.specification.planes import SILENT_PITCH_INDEX from sampletones_player.specification.registers import ( MAX_REGISTER_VALUE, TIMER_HIGH_SHIFT, + TRIANGLE_COUNTER_CONTROL, + TRIANGLE_SILENT_RELOAD, + TRIANGLE_SOUNDING_RELOAD, ) +FIRST_PITCH: Final[int] = 0 + def tone_dividers( value: bytes, @@ -24,7 +30,8 @@ def tone_dividers( This is the reading the driver performs between the pitch table and the timer registers, stated where it is testable: a flagged tick takes the bend plane's next value, and every - other tick sounds its pitch's own divider. + other tick sounds its pitch's own divider. A tick naming the index that stands for silence + holds the divider the channel last sounded, and the lowest pitch's before it sounds at all. Args: value: The pitch each tick names, under the flag saying whether it bends. @@ -35,7 +42,35 @@ def tone_dividers( Tuple[int, ...]: One divider per tick. """ bends = iter(bend) - return tuple(timers[pitch_index(named)] + (signed_byte(next(bends)) if is_flagged(named) else 0) for named in value) + dividers: List[int] = [] + held = timers[FIRST_PITCH] + for named in value: + if named != SILENT_PITCH_INDEX: + held = timers[pitch_index(named)] + (signed_byte(next(bends)) if is_flagged(named) else 0) + + dividers.append(held) + + return tuple(dividers) + + +def held_pitches(value: bytes) -> Tuple[int, ...]: + """The pitch index each tick sounds at, a rest holding the one the channel last sounded. + + Args: + value: The pitch each tick names, the index above the table naming a rest. + + Returns: + Tuple[int, ...]: One index per tick. + """ + indices: List[int] = [] + held = FIRST_PITCH + for named in value: + if named != SILENT_PITCH_INDEX: + held = pitch_index(named) + + indices.append(held) + + return tuple(indices) def _sounded( @@ -70,14 +105,23 @@ def _triangle_registers( planes: Tuple[bytes, ...], pitches: PitchTable, ) -> Tuple[TriangleRegisters, ...]: + """The triangle's ticks, its linear counter read from the pitch its value plane names. + + An index above every pitch the table holds names a rest, which silences the counter and + leaves the divider where the channel last sounded. + """ + value, bend = planes + dividers = tone_dividers(value, bend, pitches.timers) + anchors = held_pitches(value) return tuple( TriangleRegisters( - linear_counter=control, + linear_counter=TRIANGLE_COUNTER_CONTROL + | (TRIANGLE_SILENT_RELOAD if named == SILENT_PITCH_INDEX else TRIANGLE_SOUNDING_RELOAD), timer_low=timer & MAX_REGISTER_VALUE, timer_high=timer >> TIMER_HIGH_SHIFT, - anchor=anchor, + anchor=pitches.pitch(index), ) - for control, timer, anchor in _sounded(planes, pitches) + for named, timer, index in zip(value, dividers, anchors, strict=True) ) diff --git a/src/sampletones_player/compression/planes/separate.py b/src/sampletones_player/compression/planes/separate.py index 2cdb40c3a..779ae90fe 100644 --- a/src/sampletones_player/compression/planes/separate.py +++ b/src/sampletones_player/compression/planes/separate.py @@ -2,13 +2,15 @@ from sampletones_core.constants.enums import TONE_CHANNELS, ChannelName from sampletones_player.compression.pitch import PitchTable -from sampletones_player.compression.planes.flags import flagged_value, note_flags +from sampletones_player.compression.planes.flags import flagged_value, is_flagged, note_flags from sampletones_player.compression.planes.order import PlaneOrder from sampletones_player.compression.planes.song import SongPlanes from sampletones_player.registers.base import ChannelRegisters from sampletones_player.registers.streams import ChannelStreams from sampletones_player.registers.tone import ToneRegisters from sampletones_player.specification.binary import unsigned_byte +from sampletones_player.specification.planes import SILENT_PITCH_INDEX +from sampletones_player.specification.registers import TRIANGLE_SOUNDING_RELOAD CONTROL_VALUE_INDEX: Final[int] = 0 FIRST_VALUE_INDEX: Final[int] = 1 @@ -48,6 +50,32 @@ def _noise_planes(registers: Sequence[ChannelRegisters]) -> Tuple[bytes, ...]: ) +def _triangle_planes( + registers: Sequence[ChannelRegisters], + pitches: PitchTable, +) -> Tuple[bytes, ...]: + """The triangle's planes, its silence named in the pitch its value plane carries. + + The channel sounds at one level, so a tick states whether it sounds through the index it + names: the index standing above every pitch the table holds silences the linear counter, and + the divider stays where the channel last sounded. A resting tick names no pitch, so it bends + nowhere and its bend plane holds nothing for it. + """ + control, value, bend = _tone_planes(registers, pitches) + offsets = iter(bend) + named = bytearray() + bent = bytearray() + for timbre, pitch in zip(control, value, strict=True): + sounding = bool(timbre & TRIANGLE_SOUNDING_RELOAD) + flagged = is_flagged(pitch) + offset = next(offsets) if flagged else None + named.append(pitch if sounding else SILENT_PITCH_INDEX) + if sounding and offset is not None: + bent.append(offset) + + return (bytes(named), bytes(bent)) + + def channel_planes( channel: ChannelName, registers: Sequence[ChannelRegisters], @@ -72,10 +100,13 @@ def channel_planes( ValueError: If a tone channel's divider lies further from the pitch it is counted from than a signed byte states. """ - if channel in TONE_CHANNELS: - return _tone_planes(registers, pitches) - - return _noise_planes(registers) + match channel: + case ChannelName.TRIANGLE: + return _triangle_planes(registers, pitches) + case _ if channel in TONE_CHANNELS: + return _tone_planes(registers, pitches) + case _: + return _noise_planes(registers) def planes_from_streams( diff --git a/src/sampletones_player/driver/binary/driver.bin b/src/sampletones_player/driver/binary/driver.bin index e9016d04f521a971c8ec4f82b2784123dba1bb4a..f21e82732cebd1aa8102cb59e18f1ce1807b42ed 100644 GIT binary patch literal 774 zcmX|mj?Pe4jo1q4+fNOHyI3Ax4$KYsyK+@JUH`A79v$N#k|)O@$~CvE3=%EqA7f3THaEQD|QA%$1Xt{Gov#F21|FckluN) zhw#V{?W>MO@@Xxsf)UmrEjDLR{BQ4T1DiG4-_r8|BJ3KJ`iQm6A3b*KlqCtX1WU3- z$yO$tu_muPQn(u!gxR8Iuo+o<(k_U9WC?rLev?K)p4juo9(q|CSVz6~=76JCsi=s@ ZGCj)SPH9&mqe5r;`$n9i8@np8{{q45AJPB- literal 760 zcmXYvJ!lj`6vt9s@nGOayyA+jY^>x?4Q^wwOciW|t-{5xIFn?G|J(oX{fB)##@0Riyl2gM zybx3gCxJ=05hx@`6OMucvF>`-d(XP<+a9$RyaWTXJTEEr)mXC(Uo<|V_@`((9JSCU zB0qL3@jw_*Km`p_kak!)u41MvA5c)3y8MfR+8Iz6iIlUa#p+&)rMCajwR3TXY1_Z) z+Ud?;+CPl*oqw`qxB_Vr6%aImAjMUOD-J&d9{^t9&LMRNFw#M*E`>a1BkmFuSl&fY z#2sbTJ%FK(Dtp6R1D&rz#wz=K3dZ3oPv9|s2%f|1#~r)ux6l^q>b;A)__x7PSn1hU z{O7of+I*Zh!V`XnjZCS|$iOo-*o_VkItt`eGpG%(Y}XO(4K z6xQ&@irGHN^k-sC?7_NDg&y5*Oy=g((lkV_9G2JA{fd(Vv81z*#mrb70)u52hatOh za|_|FAvRX+i9*9#R0SidK~@ZBgy!+=OKpeMn_sh20V3=Ilv;>QSf44IK4K}tZV8rR zvy$Bj48mOD4R&ApN6mt`H%!=*=Bq3U^2C`kw$QUu+x||QPum<#mWrx)BBR|Ayja>) RNw=^#`pr+$p}(mS=Ra(;B&+}c diff --git a/src/sampletones_player/registers/triangle.py b/src/sampletones_player/registers/triangle.py index f7c997775..fe50da12b 100644 --- a/src/sampletones_player/registers/triangle.py +++ b/src/sampletones_player/registers/triangle.py @@ -16,6 +16,7 @@ TRIANGLE_SILENT_RELOAD, TRIANGLE_SOUNDING_RELOAD, ) +from sampletones_shared.constants.music import LIMIT_MIN_PITCH class TriangleRegisters(ToneRegisters): @@ -39,7 +40,9 @@ def from_instructions( reload every frame and the note last as long as the ticks do. The timer carries the note's divider moved by the frame's bend, and the channel sounds an - octave below the pitch it names — the same octave a rendered triangle sounds. + octave below the pitch it names — the same octave a rendered triangle sounds. A resting + tick holds the divider the channel last sounded, and the lowest pitch's before it sounds + at all, so the pitch a rest leaves unstated is one that can be reached again. Args: instructions: The channel's per-tick instructions. @@ -53,15 +56,21 @@ def from_instructions( anchors = anchored_pitches(pitches, dividers, timer_table) registers: List[TriangleRegisters] = [] + timer = timer_table[LIMIT_MIN_PITCH] + anchor = LIMIT_MIN_PITCH for index, volume in enumerate(volumes): - timer = hold(dividers, index) - reload_value = TRIANGLE_SOUNDING_RELOAD if volume > 0 else TRIANGLE_SILENT_RELOAD + sounding = volume > 0 + if sounding: + timer = hold(dividers, index) + anchor = hold(anchors, index) + + reload_value = TRIANGLE_SOUNDING_RELOAD if sounding else TRIANGLE_SILENT_RELOAD registers.append( cls( linear_counter=TRIANGLE_COUNTER_CONTROL | reload_value, timer_low=timer & MAX_REGISTER_VALUE, timer_high=timer >> TIMER_HIGH_SHIFT, - anchor=hold(anchors, index), + anchor=anchor, ) ) diff --git a/src/sampletones_player/specification/planes.py b/src/sampletones_player/specification/planes.py index 7df3cc294..4db971e5e 100644 --- a/src/sampletones_player/specification/planes.py +++ b/src/sampletones_player/specification/planes.py @@ -8,6 +8,7 @@ from sampletones_core.constants.enums import ChannelName from sampletones_core.constants.general import MAX_DUTY_CYCLE, MAX_PERIOD, MAX_VOLUME from sampletones_player.specification.binary import MAX_BYTE_VALUE +from sampletones_player.specification.compression import PITCH_INDEX_MASK from sampletones_player.specification.registers import ( DUTY_CYCLE_SHIFT, NOISE_MODE_SHIFT, @@ -17,6 +18,7 @@ SINGLE_TICK: Final[int] = 1 NO_BITS: Final[int] = 0 COUNT_STEP: Final[int] = 0x10 +SILENT_PITCH_INDEX: Final[int] = PITCH_INDEX_MASK class PlaneForm(BaseModel): @@ -249,8 +251,12 @@ def _tone( ), _tone(ChannelName.PULSE2, PlaneRole.VALUE), _tone(ChannelName.PULSE2, PlaneRole.BEND), - _tone(ChannelName.TRIANGLE, PlaneRole.CONTROL), - _tone(ChannelName.TRIANGLE, PlaneRole.VALUE), + Plane( + channel=ChannelName.TRIANGLE, + role=PlaneRole.VALUE, + form=WHOLE_BYTE, + seeded=SILENT_PITCH_INDEX, + ), _tone(ChannelName.TRIANGLE, PlaneRole.BEND), Plane( channel=ChannelName.NOISE, diff --git a/src/sampletones_tools/codec/study/layouts/tone.py b/src/sampletones_tools/codec/study/layouts/tone.py index 6b05c20d2..cf5849ed1 100644 --- a/src/sampletones_tools/codec/study/layouts/tone.py +++ b/src/sampletones_tools/codec/study/layouts/tone.py @@ -6,6 +6,7 @@ from sampletones_player.compression.planes.rebuild import tone_dividers from sampletones_player.registers.dividers import anchored_pitches from sampletones_player.specification.binary import unsigned_byte +from sampletones_player.specification.planes import SILENT_PITCH_INDEX from sampletones_tools.codec.study.layouts.layout import Anchor, BendForm, PlaneLayout @@ -77,8 +78,11 @@ def tone_planes( notes: bytes, pitches: PitchTable, layout: PlaneLayout, -) -> Tuple[bytes, bytes, bytes]: - """A tone channel's control, value and bend planes written under a layout. +) -> Tuple[bytes, ...]: + """A tone channel's planes written under a layout, the divider read across value and bend. + + A tick naming the index that stands for silence keeps it, since a layout moves where a + divider is written rather than whether the channel sounds. Args: planes: The channel's planes as the production codec separates them. @@ -87,18 +91,25 @@ def tone_planes( layout: How the divider is written across the value and the bend. Returns: - Tuple[bytes, bytes, bytes]: The control, value and bend planes; a flagged bend plane holds - only the ticks its value plane flags. + Tuple[bytes, ...]: The channel's planes as the song block writes them; a flagged bend + plane holds only the ticks its value plane flags. Raises: ValueError: If a flagged layout meets an index reaching the flag's bit. """ - control, named, offsets = planes + *timbre, named, offsets = planes + resting = tuple(pitch == SILENT_PITCH_INDEX for pitch in named) anchored = layout_anchors(tone_dividers(named, offsets, pitches.timers), notes, pitches, layout.anchor) - flags = bend_flags(anchored, layout.form) + flags = tuple(flag and not rest for flag, rest in zip(bend_flags(anchored, layout.form), resting, strict=True)) bend = bytes(unsigned_byte(pitch.offset) for pitch, flag in zip(anchored, flags) if flag) if layout.form is BendForm.DENSE: - return control, bytes(pitch.pitch for pitch in anchored), bend - - value = bytes(flagged_value(pitch.pitch, flag) for pitch, flag in zip(anchored, flags)) - return control, value, bend + spelled = bytes( + SILENT_PITCH_INDEX if rest else pitch.pitch for rest, pitch in zip(resting, anchored, strict=True) + ) + return (*timbre, spelled, bend) + + value = bytes( + SILENT_PITCH_INDEX if rest else flagged_value(pitch.pitch, flag) + for rest, pitch, flag in zip(resting, anchored, flags, strict=True) + ) + return (*timbre, value, bend) diff --git a/src/sampletones_tools/player/assembly/include/song.inc b/src/sampletones_tools/player/assembly/include/song.inc index 65be41faa..306b6c188 100644 --- a/src/sampletones_tools/player/assembly/include/song.inc +++ b/src/sampletones_tools/player/assembly/include/song.inc @@ -1,5 +1,5 @@ WORD_SIZE = 2 -PLANE_COUNT = 11 +PLANE_COUNT = 10 STEP_WHOLE_OFFSET = 0 STEP_FRACTION_OFFSET = STEP_WHOLE_OFFSET + 1 @@ -18,6 +18,10 @@ TICK_PLAYS = 1 TICK_REPEATED = 2 PITCH_COUNT = 104 +SILENT_PITCH_INDEX = $7F +TRIANGLE_COUNTER = $80 +TRIANGLE_SOUNDING = $7F +TRIANGLE_SILENT = $00 BEND_FLAG = $80 PITCH_INDEX_MASK = BEND_FLAG - 1 @@ -61,8 +65,7 @@ PULSE1_BEND_PLANE = 2 * PLANE_STATE_SIZE PULSE2_CONTROL_PLANE = 3 * PLANE_STATE_SIZE PULSE2_VALUE_PLANE = 4 * PLANE_STATE_SIZE PULSE2_BEND_PLANE = 5 * PLANE_STATE_SIZE -TRIANGLE_CONTROL_PLANE = 6 * PLANE_STATE_SIZE -TRIANGLE_VALUE_PLANE = 7 * PLANE_STATE_SIZE -TRIANGLE_BEND_PLANE = 8 * PLANE_STATE_SIZE -NOISE_CONTROL_PLANE = 9 * PLANE_STATE_SIZE -NOISE_VALUE_PLANE = 10 * PLANE_STATE_SIZE +TRIANGLE_VALUE_PLANE = 6 * PLANE_STATE_SIZE +TRIANGLE_BEND_PLANE = 7 * PLANE_STATE_SIZE +NOISE_CONTROL_PLANE = 8 * PLANE_STATE_SIZE +NOISE_VALUE_PLANE = 9 * PLANE_STATE_SIZE diff --git a/src/sampletones_tools/player/assembly/source/channels.s b/src/sampletones_tools/player/assembly/source/channels.s index 55e7107e3..6b9b88a76 100644 --- a/src/sampletones_tools/player/assembly/source/channels.s +++ b/src/sampletones_tools/player/assembly/source/channels.s @@ -12,6 +12,7 @@ SHADOW_UNWRITTEN = $FF ABSENT_PAGE = $00 +LOWEST_PITCH_INDEX = $00 .segment "ZEROPAGE" @@ -23,6 +24,7 @@ timer_low_table: .res 2 timer_high_table: .res 2 bend: .res 1 bend_sign: .res 1 +triangle_pitch: .res 1 plane_state: .res PLANE_STATE_BYTES timer_high_shadows: .res TRIANGLE_REGISTERS + 1 @@ -35,11 +37,16 @@ timer_high_shadows: .res TRIANGLE_REGISTERS + 1 .assert ABSENT_STREAM = $FFFF, error, "an absent stream is told apart by both its bytes reading $FF" .assert BEND_FLAG = $80, error, "a value's flag is read as the sign bit" .assert PITCH_COUNT <= BEND_FLAG, error, "every pitch index fits below the flag" +.assert SILENT_PITCH_INDEX >= PITCH_COUNT, error, "the index standing for silence sounds no pitch" +.assert SILENT_PITCH_INDEX < BEND_FLAG, error, "the index standing for silence bends nowhere" .assert COUNT_STEP = 1 << COUNT_SHIFT, error, "a repeat count steps from the bit it is shifted down by" .assert PLANE_STATE_BYTES + PLANE_PHRASE < $100, error, "a plane's phrase pointer is reached in page zero" ; Readies the tables the planes read through and points every plane at its own first token. channels_reset: + lda #LOWEST_PITCH_INDEX + sta triangle_pitch + lda #SHADOW_UNWRITTEN sta timer_high_shadows + PULSE1_REGISTERS sta timer_high_shadows + PULSE2_REGISTERS @@ -73,19 +80,7 @@ channels_reset: sta pointer lda #>(song_data + STREAM_OFFSETS_OFFSET) sta pointer + 1 - jsr seed_planes - -; States the bits each plane's register leaves for a repeat count. Every other plane keeps the -; zero seeding left it, which is what makes one symbol cover one tick. -plane_forms: - lda #(PULSE_CONTROL_MASK ^ $FF) - sta plane_state + PULSE1_CONTROL_PLANE + PLANE_COUNT_MASK - sta plane_state + PULSE2_CONTROL_PLANE + PLANE_COUNT_MASK - lda #(NOISE_CONTROL_MASK ^ $FF) - sta plane_state + NOISE_CONTROL_PLANE + PLANE_COUNT_MASK - lda #(NOISE_VALUE_MASK ^ $FF) - sta plane_state + NOISE_VALUE_PLANE + PLANE_COUNT_MASK - rts + jmp seed_planes ; Brings every plane back to the token its stream is re-entered at, which is the whole of what a ; song coming round restores: the token the loop tick starts is one the plane reads outright. @@ -138,6 +133,20 @@ seed_planes: tax cpx #PLANE_STATE_BYTES bne @next + +; States what each plane's byte holds: the bits its register leaves for a repeat count, and for +; the triangle the index its value plane stands at while the channel rests. Every other plane +; keeps the zero it was seeded with, which is what makes one symbol cover one tick. +plane_forms: + lda #(PULSE_CONTROL_MASK ^ $FF) + sta plane_state + PULSE1_CONTROL_PLANE + PLANE_COUNT_MASK + sta plane_state + PULSE2_CONTROL_PLANE + PLANE_COUNT_MASK + lda #(NOISE_CONTROL_MASK ^ $FF) + sta plane_state + NOISE_CONTROL_PLANE + PLANE_COUNT_MASK + lda #(NOISE_VALUE_MASK ^ $FF) + sta plane_state + NOISE_VALUE_PLANE + PLANE_COUNT_MASK + lda #SILENT_PITCH_INDEX + sta plane_state + TRIANGLE_VALUE_PLANE + PLANE_VALUE rts ; Advances every plane the block holds by one tick of the song. A bend plane advances only on a @@ -161,8 +170,6 @@ channels_advance: ldx #PULSE2_BEND_PLANE jsr plane_step @triangle: - ldx #TRIANGLE_CONTROL_PLANE - jsr plane_step ldx #TRIANGLE_VALUE_PLANE jsr plane_step bit plane_state + TRIANGLE_VALUE_PLANE + PLANE_VALUE @@ -351,7 +358,9 @@ set_phrase: tay rts -; Writes what every plane last played to the registers its channel owns. +; Writes what every plane last played to the registers its channel owns. The triangle names its +; silence in the pitch its value plane carries: the index standing above every pitch the table +; holds silences the linear counter and leaves the divider where the channel last sounded. channels_write: lda plane_state + PULSE1_CONTROL_PLANE + PLANE_VALUE and #PULSE_CONTROL_MASK @@ -373,12 +382,20 @@ channels_write: lda plane_state + PULSE2_VALUE_PLANE + PLANE_VALUE jsr write_timer - lda plane_state + TRIANGLE_CONTROL_PLANE + PLANE_VALUE + lda plane_state + TRIANGLE_VALUE_PLANE + PLANE_VALUE + cmp #SILENT_PITCH_INDEX + beq @resting + sta triangle_pitch + lda #TRIANGLE_COUNTER | TRIANGLE_SOUNDING + bne @counter +@resting: + lda #TRIANGLE_COUNTER | TRIANGLE_SILENT +@counter: sta CHANNEL_CONTROL + TRIANGLE_REGISTERS lda plane_state + TRIANGLE_BEND_PLANE + PLANE_VALUE sta bend ldx #TRIANGLE_REGISTERS - lda plane_state + TRIANGLE_VALUE_PLANE + PLANE_VALUE + lda triangle_pitch jsr write_timer lda plane_state + NOISE_CONTROL_PLANE + PLANE_VALUE diff --git a/tests/integration/tooling/test_codec_study.py b/tests/integration/tooling/test_codec_study.py index eb8449034..7f8802b5b 100644 --- a/tests/integration/tooling/test_codec_study.py +++ b/tests/integration/tooling/test_codec_study.py @@ -16,7 +16,7 @@ from sampletones_tools.codec.study.session import run_study VARIANT: Final[str] = "wide-hold" -PACKED_VARIANT: Final[str] = "packed-table" +BASELINE_VARIANT: Final[str] = "baseline" LENGTHEN_SECONDS: Final[int] = 2 @@ -57,7 +57,7 @@ def test_a_run_writes_its_tables_and_the_manifest_that_repeats_it(self, plan: St start = markdown.index(table[0]) assert markdown[start : start + len(table)] == table - def test_a_packed_plane_run_reads_back_as_the_song_it_was_written_from( + def test_a_run_reads_every_encoding_back_as_the_song_it_was_written_from( self, integration_project: Project, tmp_path: Path, @@ -70,7 +70,7 @@ def test_a_packed_plane_run_reads_back_as_the_song_it_was_written_from( projects=(StudySource.at(project),), reconstructions=(), lengthen_seconds=LENGTHEN_SECONDS, - variants=(PACKED_VARIANT,), + variants=(BASELINE_VARIANT,), ) ) @@ -78,7 +78,7 @@ def test_a_packed_plane_run_reads_back_as_the_song_it_was_written_from( header, *measured = _read_csv(directory / REPORT_CSV) variant = header.index("variant") - assert {row[variant] for row in measured} >= {PACKED_VARIANT} + assert {row[variant] for row in measured} >= {BASELINE_VARIANT} def test_a_source_that_fails_to_read_leaves_the_output_untouched(self, tmp_path: Path) -> None: broken = tmp_path / "broken.stp" diff --git a/tests/suite/player.py b/tests/suite/player.py index 6a9d4222d..c3a27cb4b 100644 --- a/tests/suite/player.py +++ b/tests/suite/player.py @@ -90,14 +90,22 @@ def pulse_tick( ) +RESTING_PITCH: Final[int] = 0 + + def triangle_tick(sounding: bool, timer: int) -> TriangleRegisters: - """A triangle channel's registers for one tick, spelled the way the encoder spells them.""" + """A triangle channel's registers for one tick, spelled the way the encoder spells them. + + A resting tick states no pitch, so it carries the divider the channel would be holding: the + lowest the table reaches, which is where the encoder leaves a channel that has yet to sound. + """ reload_value = TRIANGLE_SOUNDING_RELOAD if sounding else TRIANGLE_SILENT_RELOAD + held = timer if sounding else PLAYER_PITCHES.timers[RESTING_PITCH] return TriangleRegisters( linear_counter=TRIANGLE_COUNTER_CONTROL | reload_value, - timer_low=timer & MAX_REGISTER_VALUE, - timer_high=timer >> TIMER_HIGH_SHIFT, - anchor=nearest_anchor(timer), + timer_low=held & MAX_REGISTER_VALUE, + timer_high=held >> TIMER_HIGH_SHIFT, + anchor=nearest_anchor(held), ) diff --git a/tests/unit/sampletones_player/nsf/test_song.py b/tests/unit/sampletones_player/nsf/test_song.py index 9b9c2c9cc..bc62d082e 100644 --- a/tests/unit/sampletones_player/nsf/test_song.py +++ b/tests/unit/sampletones_player/nsf/test_song.py @@ -89,9 +89,10 @@ class TestSongBytes: full: the header, the timer every pitch sounds at, the dictionary the tokens name, and one token stream per plane the block holds. The song bends nowhere, so each bend plane is absent; its second pulse channel and its noise channel rest throughout, so both control planes stand - at the value their register fixes and are absent too. Each absent plane's header entries carry - the sentinel. The timer table is named rather than transcribed, since it is the tuning's own - table and the block carries whatever that table holds. + at the value their register fixes; and its triangle never sounds, so its value plane names the + index that stands for silence. Each of those is absent, and its header entries carry the + sentinel. The timer table is named rather than transcribed, since it is the tuning's own table + and the block carries whatever that table holds. """ EXPECTED_HEADER: Final[bytes] = ( @@ -99,21 +100,19 @@ class TestSongBytes: b"\xca\x7f" b"\x02\x00" b"\xff\xff" - b"\x37\x00" - b"\x07\x01" - b"\x08\x01\x0b\x01\xff\xff" - b"\xff\xff\x0e\x01\xff\xff" - b"\x11\x01\x14\x01\xff\xff" - b"\xff\xff\x17\x01" - b"\x08\x01\x0b\x01\xff\xff" - b"\xff\xff\x0e\x01\xff\xff" - b"\x11\x01\x14\x01\xff\xff" - b"\xff\xff\x17\x01" + b"\x33\x00" + b"\x03\x01" + b"\x04\x01\x07\x01\xff\xff" + b"\xff\xff\x0a\x01\xff\xff" + b"\xff\xff\xff\xff" + b"\xff\xff\x0d\x01" + b"\x04\x01\x07\x01\xff\xff" + b"\xff\xff\x0a\x01\xff\xff" + b"\xff\xff\xff\xff" + b"\xff\xff\x0d\x01" ) - EXPECTED_STREAMS: Final[bytes] = ( - b"\x00" b"\x41\x0f\x00" b"\x40\x21\x00" b"\x40\x21\x00" b"\x40\x80\x00" b"\x40\x21\x00" b"\x40\x1a" - ) + EXPECTED_STREAMS: Final[bytes] = b"\x00" b"\x41\x0f\x00" b"\x40\x21\x00" b"\x40\x21\x00" b"\x40\x1a" def test_the_song_serializes_to_the_expected_bytes(self) -> None: song = two_tick_song(HALF_RATE_FREQUENCY) diff --git a/tests/unit/sampletones_player/specification/test_planes.py b/tests/unit/sampletones_player/specification/test_planes.py index 68eea092d..3c30e0e9f 100644 --- a/tests/unit/sampletones_player/specification/test_planes.py +++ b/tests/unit/sampletones_player/specification/test_planes.py @@ -2,12 +2,14 @@ import pytest -from sampletones_core.constants.enums import TONE_CHANNELS, ChannelName +from sampletones_core.constants.enums import ALL_CHANNELS, TONE_CHANNELS, ChannelName +from sampletones_player.compression.pitch import PITCH_COUNT from sampletones_player.compression.planes.order import PlaneOrder from sampletones_player.specification.planes import ( PLANE_COUNT, PLANE_NAMES, PLANES, + SILENT_PITCH_INDEX, PlaneRole, channel_indices, plane_index, @@ -19,11 +21,22 @@ class TestOneTableStatesEveryPlaneASongBlockWrites: """The song block writes its planes in one order, and the table is where that order is stated.""" - def test_every_channel_writes_what_it_sounds_and_how_it_sounds_it(self) -> None: + def test_every_channel_names_what_it_sounds(self) -> None: for channel in ChannelName.items(): roles = {PLANES[index].role for index in channel_indices(channel)} - assert {PlaneRole.CONTROL, PlaneRole.VALUE} <= roles + assert PlaneRole.VALUE in roles + + def test_a_control_belongs_to_each_channel_whose_timbre_turns_over(self) -> None: + timbred = {plane.channel for plane in PLANES if plane.role is PlaneRole.CONTROL} + + assert timbred == ALL_CHANNELS - {ChannelName.TRIANGLE} + + def test_the_triangle_names_its_silence_in_the_pitch_it_stands_at(self) -> None: + triangle = PLANES[plane_index(ChannelName.TRIANGLE, PlaneRole.VALUE)] + + assert triangle.seeded == SILENT_PITCH_INDEX + assert triangle.seeded >= PITCH_COUNT def test_a_bend_belongs_to_each_channel_whose_divider_moves(self) -> None: bending = {PLANES[index].channel for index, plane in enumerate(PLANES) if plane.spans_flagged_ticks} diff --git a/tests/unit/sampletones_tools/codec/study/test_accounting.py b/tests/unit/sampletones_tools/codec/study/test_accounting.py index f22839b39..f8ceadc51 100644 --- a/tests/unit/sampletones_tools/codec/study/test_accounting.py +++ b/tests/unit/sampletones_tools/codec/study/test_accounting.py @@ -12,6 +12,7 @@ from sampletones_player.compression.tokens.phrase import PhraseToken from sampletones_player.compression.tokens.types import TokenUnion from sampletones_player.specification.compression import MAX_HOLD_TICKS, TokenTag +from sampletones_player.specification.planes import PLANES, PlaneRole from sampletones_tools.codec.study.accounting.coincident import coincident_starts from sampletones_tools.codec.study.accounting.dictionary import default_counts, plateaus from sampletones_tools.codec.study.accounting.finding import Finding @@ -218,13 +219,17 @@ def _starting_at(ticks: Sequence[int]) -> Tuple[ReadToken, ...]: class TestCoincidentStarts: + """Every channel carrying a timbre shares its first tick, and pulse one shares a second.""" + def test_a_channel_counts_the_ticks_both_its_planes_start_a_token_on(self) -> None: tokens: Dict[str, Tuple[ReadToken, ...]] = {name: _starting_at((0,)) for name in PlaneOrder.names()} tokens["pulse1_control"] = _starting_at((0, 5)) tokens["pulse1_value"] = _starting_at((0, 5, 9)) tokens["noise_value"] = _starting_at((0, 7)) + timbred = sum(1 for plane in PLANES if plane.role is PlaneRole.CONTROL) + shared = timbred + 1 - assert coincident_starts(tokens) == Finding(10, 5) + assert coincident_starts(tokens) == Finding(2 * shared, shared) class TestPlateausInBodies: diff --git a/tests/unit/sampletones_tools/codec/study/test_sandbox.py b/tests/unit/sampletones_tools/codec/study/test_sandbox.py index de8b0c517..a2731324e 100644 --- a/tests/unit/sampletones_tools/codec/study/test_sandbox.py +++ b/tests/unit/sampletones_tools/codec/study/test_sandbox.py @@ -399,4 +399,5 @@ def test_a_plane_holding_zero_throughout_is_absent_under_every_grammar(self, ent encoding = encode_grammar(read, entry.grammar) - assert [encoding.streams[plane] for plane in range(2, PLANE_COUNT, 3)] == [0, 0, 0] + bends = [index for index, plane in enumerate(PLANES) if plane.spans_flagged_ticks] + assert [encoding.streams[index] for index in bends] == [0] * len(bends) diff --git a/tests/unit/sampletones_tools/player/test_song_include.py b/tests/unit/sampletones_tools/player/test_song_include.py index 17c8e72ac..aa6d78783 100644 --- a/tests/unit/sampletones_tools/player/test_song_include.py +++ b/tests/unit/sampletones_tools/player/test_song_include.py @@ -26,6 +26,12 @@ NOISE_VALUE_FORM, PLANE_COUNT, PULSE_CONTROL_FORM, + SILENT_PITCH_INDEX, +) +from sampletones_player.specification.registers import ( + TRIANGLE_COUNTER_CONTROL, + TRIANGLE_SILENT_RELOAD, + TRIANGLE_SOUNDING_RELOAD, ) from sampletones_player.specification.song import ( ABSENT_STREAM, @@ -76,6 +82,10 @@ "PLANE_STATE_SIZE": PLANE_STATE_SIZE, "PLANE_STATE_BYTES": PLANE_COUNT * PLANE_STATE_SIZE, "COUNT_STEP": COUNT_STEP, + "SILENT_PITCH_INDEX": SILENT_PITCH_INDEX, + "TRIANGLE_COUNTER": TRIANGLE_COUNTER_CONTROL, + "TRIANGLE_SOUNDING": TRIANGLE_SOUNDING_RELOAD, + "TRIANGLE_SILENT": TRIANGLE_SILENT_RELOAD, "PULSE_CONTROL_MASK": PULSE_CONTROL_FORM.value_mask, "PULSE_CONTROL_FIXED": PULSE_CONTROL_FORM.value_or, "NOISE_CONTROL_MASK": NOISE_CONTROL_FORM.value_mask, From 3aca4d60e617ad7afa130879f1055c81ff8f7cf7 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 21 Sep 2026 14:24:17 +0200 Subject: [PATCH 06/11] Read: a phrase's own count from the token that leaves it unstated --- src/sampletones_player/compression/admit.py | 4 +- src/sampletones_player/compression/decode.py | 14 ++++-- .../compression/dictionary/phrase.py | 6 ++- .../compression/dictionary/prune.py | 10 +++- src/sampletones_player/compression/encode.py | 44 +++++++++++++++-- src/sampletones_player/compression/entries.py | 10 +++- .../compression/matches/matcher.py | 6 +++ .../compression/parse/plane.py | 13 ++++- src/sampletones_player/compression/search.py | 4 +- .../compression/tokens/phrase.py | 6 ++- .../compression/tokens/sizes.py | 14 ++++-- .../compression/tokens/span.py | 34 +++++++++---- .../driver/binary/driver.bin | Bin 774 -> 790 bytes src/sampletones_player/nsf/layout.py | 3 +- src/sampletones_player/nsf/song.py | 6 +++ .../specification/compression.py | 10 +++- .../codec/study/accounting/dictionary.py | 38 +-------------- .../codec/study/accounting/rows.py | 7 +-- .../codec/study/accounting/tokens.py | 20 ++++++-- .../codec/study/sandbox/costs.py | 6 +-- .../codec/study/sandbox/grammar.py | 2 +- .../codec/study/sandbox/reference.py | 4 +- .../codec/study/variants/sandbox.py | 30 +----------- .../player/assembly/include/song.inc | 5 +- .../player/assembly/source/channels.s | 17 +++++-- .../compression/dictionary/test_phrase.py | 5 +- .../compression/dictionary/test_prune.py | 11 +++-- .../compression/parse/test_plane.py | 4 +- .../compression/test_encode.py | 14 ++++-- .../compression/test_entries.py | 16 +++--- .../compression/tokens/test_phrase.py | 4 +- .../compression/tokens/test_span.py | 29 ++++++----- .../sampletones_player/nsf/test_layout.py | 3 +- .../unit/sampletones_player/nsf/test_song.py | 7 ++- .../codec/study/test_accounting.py | 46 +++++++----------- .../codec/study/test_sandbox.py | 12 ++--- .../player/test_song_include.py | 6 +++ 37 files changed, 275 insertions(+), 195 deletions(-) diff --git a/src/sampletones_player/compression/admit.py b/src/sampletones_player/compression/admit.py index 9cca4fd97..a182b8a07 100644 --- a/src/sampletones_player/compression/admit.py +++ b/src/sampletones_player/compression/admit.py @@ -95,8 +95,8 @@ def _payment( if played == 0: return 0 - stated = phrase_size(CROWDED_PHRASE_ID, UNSHIFTED_TOKEN_TRANSPOSE) - shifted = phrase_size(CROWDED_PHRASE_ID, SHIFTED_TOKEN_TRANSPOSE) + stated = phrase_size(CROWDED_PHRASE_ID, UNSHIFTED_TOKEN_TRANSPOSE, default=False) + shifted = phrase_size(CROWDED_PHRASE_ID, SHIFTED_TOKEN_TRANSPOSE, default=False) spent = stated + shifted * (played - 1) + phrase_entry_size(phrase.length) return paid - spent diff --git a/src/sampletones_player/compression/decode.py b/src/sampletones_player/compression/decode.py index 8f100da9d..ef4ff72fe 100644 --- a/src/sampletones_player/compression/decode.py +++ b/src/sampletones_player/compression/decode.py @@ -8,7 +8,9 @@ from sampletones_player.compression.planes.symbols import unpack_plane from sampletones_player.specification.binary import BYTE_VALUES from sampletones_player.specification.compression import ( + DEFAULT_COUNT_FLAG, PHRASE_ID_ESCAPE, + PHRASE_ID_MASK, TOKEN_OPERAND_MASK, TOKEN_TAG_MASK, TokenTag, @@ -30,13 +32,17 @@ def _phrase_values( *, transposed: bool, ) -> Tuple[bytes, int]: - phrase_id = operand - if operand == PHRASE_ID_ESCAPE: + phrase_id = operand & PHRASE_ID_MASK + if phrase_id == PHRASE_ID_ESCAPE: phrase_id = data[position] position += 1 - ticks = data[position] + 1 - position += 1 + if operand & DEFAULT_COUNT_FLAG: + ticks = table[phrase_id].default + else: + ticks = data[position] + 1 + position += 1 + transpose = 0 if transposed: transpose = data[position] diff --git a/src/sampletones_player/compression/dictionary/phrase.py b/src/sampletones_player/compression/dictionary/phrase.py index ea8fabe6d..72cbdc112 100644 --- a/src/sampletones_player/compression/dictionary/phrase.py +++ b/src/sampletones_player/compression/dictionary/phrase.py @@ -7,6 +7,8 @@ from sampletones_player.specification.binary import BYTE_VALUES from sampletones_player.specification.compression import ( MAX_PHRASE_LENGTH, + NO_DEFAULT_COUNT, + PHRASE_DEFAULT_SIZE, PHRASE_LENGTH_SIZE, PHRASE_TABLE_ENTRY_SIZE, ) @@ -24,7 +26,7 @@ def phrase_entry_size(length: int) -> int: Returns: int: The bytes the phrase and its table entry take together. """ - return PHRASE_TABLE_ENTRY_SIZE + PHRASE_LENGTH_SIZE + length + return PHRASE_TABLE_ENTRY_SIZE + PHRASE_LENGTH_SIZE + PHRASE_DEFAULT_SIZE + length class Phrase(BaseModel): @@ -35,11 +37,13 @@ class Phrase(BaseModel): Attributes: body: The values the phrase plays, one per tick. + default: The count its tokens play it at most often, which a token may leave unstated. """ model_config = ConfigDict(extra="forbid", frozen=True) body: bytes + default: int = NO_DEFAULT_COUNT @model_validator(mode="after") def _validate_the_body_fits_a_table_entry(self) -> Phrase: diff --git a/src/sampletones_player/compression/dictionary/prune.py b/src/sampletones_player/compression/dictionary/prune.py index 2b9e60254..50fce0792 100644 --- a/src/sampletones_player/compression/dictionary/prune.py +++ b/src/sampletones_player/compression/dictionary/prune.py @@ -24,21 +24,27 @@ def prune( table: PhraseTable, references: Mapping[int, int], savings: Mapping[int, int], + counts: Mapping[int, int], ) -> PhraseTable: """Rebuilds a table around the phrases that pay for themselves, most named first. A phrase earns its place by sparing the streams more bytes than its own entry takes, and the ids it competes for are worth a byte apiece: the cheap ones ride inside an opcode, so the - phrases named most often take them and the tokens naming those shed a byte each. + phrases named most often take them and the tokens naming those shed a byte each. Each phrase + kept carries the count its own tokens play it at most often, which is the count a token may + then leave unstated. Args: table: The table the tokens were parsed against. references: How many tokens name each phrase id. savings: The bytes each phrase's tokens spare the streams. + counts: The count each phrase's tokens play it at most often. Returns: PhraseTable: The phrases worth keeping, ordered by how often they are named. """ kept = _pays_for_itself(table, references, savings) kept.sort(key=lambda entry: (-entry.references, entry.phrase_id)) - return PhraseTable(phrases=tuple(table[entry.phrase_id] for entry in kept)) + return PhraseTable( + phrases=tuple(table[entry.phrase_id].model_copy(update={"default": counts[entry.phrase_id]}) for entry in kept) + ) diff --git a/src/sampletones_player/compression/encode.py b/src/sampletones_player/compression/encode.py index f5639100a..fca22584c 100644 --- a/src/sampletones_player/compression/encode.py +++ b/src/sampletones_player/compression/encode.py @@ -1,5 +1,6 @@ +from collections import Counter from dataclasses import replace -from typing import Dict, Final, FrozenSet, Iterable, Sequence, Tuple +from typing import Dict, Final, FrozenSet, Iterable, List, Sequence, Tuple from sampletones_player.compression.admit import admit_seeds from sampletones_player.compression.budget import DEFAULT_SEARCH_BUDGET, SearchBudget @@ -25,7 +26,12 @@ from sampletones_player.compression.tokens.literal import LiteralToken from sampletones_player.compression.tokens.phrase import PhraseToken from sampletones_player.compression.tokens.types import TokenUnion -from sampletones_player.specification.compression import PHRASE_ID_ESCAPE, TokenTag +from sampletones_player.specification.compression import ( + DEFAULT_COUNT_FLAG, + NO_DEFAULT_COUNT, + PHRASE_ID_ESCAPE, + TokenTag, +) from sampletones_player.specification.planes import PLANES from sampletones_shared.utils.progress import silent_reporter @@ -53,11 +59,13 @@ def emit(tokens: Sequence[TokenUnion]) -> bytes: case PhraseToken(): tag = TokenTag.TRANSPOSED_PHRASE if token.transpose else TokenTag.PHRASE named = min(token.phrase_id, PHRASE_ID_ESCAPE) - stream.append(tag | named) + stream.append(tag | (DEFAULT_COUNT_FLAG if token.default else 0) | named) if named == PHRASE_ID_ESCAPE: stream.append(token.phrase_id) - stream.append(token.ticks - 1) + if not token.default: + stream.append(token.ticks - 1) + if token.transpose: stream.append(token.transpose) @@ -92,6 +100,25 @@ def _savings( return savings +def _modal(played: Counter[int]) -> int: + """The count a phrase's tokens play it at most often, the shortest breaking a tie.""" + if not played: + return NO_DEFAULT_COUNT + + return min(played, key=lambda ticks: (-played[ticks], ticks)) + + +def _counts(parses: Iterable[Parse], phrases: int) -> Dict[int, int]: + """The count each phrase's tokens play it at most often, the shortest breaking a tie.""" + played: List[Counter[int]] = [Counter() for _ in range(phrases)] + for parse in parses: + for token in parse.tokens: + if isinstance(token, PhraseToken): + played[token.phrase_id][token.ticks] += 1 + + return {phrase_id: _modal(counter) for phrase_id, counter in enumerate(played)} + + def _settle( cache: MatchCache, table: PhraseTable, @@ -100,12 +127,19 @@ def _settle( monitor: CodecMonitor, baseline: Sequence[Parse], ) -> Tuple[PhraseTable, Tuple[Parse, ...]]: + """The table and its parses settled together, each round reading the other. + + A phrase's place, its id and the count its tokens leave unstated are all read off a parse + that was made under the table before it, so the table is rebuilt and the planes read again + until the two stand still. + """ parses = parse_planes(cache, table, options, boundaries, monitor) for _ in range(SETTLING_ROUNDS): pruned = prune( table, _references(parses, len(table)), _savings(parses, baseline, len(table)), + _counts(parses, len(table)), ) if pruned.phrases == table.phrases: break @@ -261,7 +295,7 @@ def encode_planes( streams=PlaneOrder.across(streams), ticks=planes.ticks, loop_entries=tuple( - stream_entry(stream, next(iter(entry), STREAM_START)) if stream else None + stream_entry(stream, next(iter(entry), STREAM_START), table) if stream else None for stream, entry in zip(streams, returned, strict=True) ), ) diff --git a/src/sampletones_player/compression/entries.py b/src/sampletones_player/compression/entries.py index 5cf25d643..ab3ffe58b 100644 --- a/src/sampletones_player/compression/entries.py +++ b/src/sampletones_player/compression/entries.py @@ -1,7 +1,12 @@ +from sampletones_player.compression.dictionary.table import PhraseTable from sampletones_player.compression.tokens.span import token_span -def stream_entry(stream: bytes, position: int) -> int: +def stream_entry( + stream: bytes, + position: int, + table: PhraseTable, +) -> int: """The byte of ``stream`` the token covering the value at ``position`` begins at. A song that repeats re-enters its streams partway through, and what the driver needs to @@ -11,6 +16,7 @@ def stream_entry(stream: bytes, position: int) -> int: Args: stream: The plane's token stream. position: The value the stream is re-entered at. + table: The dictionary the tokens name. Returns: int: The byte the token covering ``position`` begins at, counted from the stream's own @@ -22,7 +28,7 @@ def stream_entry(stream: bytes, position: int) -> int: offset = 0 reached = 0 while reached < position: - span = token_span(stream, offset) + span = token_span(stream, offset, table) reached += span.ticks offset += span.size diff --git a/src/sampletones_player/compression/matches/matcher.py b/src/sampletones_player/compression/matches/matcher.py index e1f8a013d..9c2fd2604 100644 --- a/src/sampletones_player/compression/matches/matcher.py +++ b/src/sampletones_player/compression/matches/matcher.py @@ -44,6 +44,7 @@ def __init__( cache: MatchCache, ) -> None: self._index: PlaneIndex = cache.index(plane) + self._table: PhraseTable = table self._origins: Tuple[int, ...] = tuple(phrase.body[0] for phrase in table.phrases) self._played: Tuple[Sequence[int], ...] = tuple(cache.reading(plane, phrase).ticks for phrase in table.phrases) keyed: Dict[bytes, List[int]] = {} @@ -59,6 +60,11 @@ def __init__( self._keyed: Dict[bytes, Tuple[int, ...]] = {key: tuple(ids) for key, ids in keyed.items()} self._short: Tuple[int, ...] = tuple(short) + @property + def table(self) -> PhraseTable: + """The dictionary the matcher names phrases from.""" + return self._table + @property def index(self) -> PlaneIndex: """The plane the matcher answers for, and the readings of it matching is decided against.""" diff --git a/src/sampletones_player/compression/parse/plane.py b/src/sampletones_player/compression/parse/plane.py index 53ce7146e..9ef8054a2 100644 --- a/src/sampletones_player/compression/parse/plane.py +++ b/src/sampletones_player/compression/parse/plane.py @@ -19,6 +19,7 @@ MAX_HOLD_TICKS, MAX_LITERAL_BYTES, MAX_PHRASE_TICKS, + NO_DEFAULT_COUNT, ) @@ -69,9 +70,17 @@ def _relax_forward( shortest.relax( position, position + ticks, - cost + phrase_size(phrase_id, transpose), - PhraseToken(phrase_id=phrase_id, ticks=ticks, transpose=transpose), + cost + phrase_size(phrase_id, transpose, default=False), + PhraseToken(phrase_id=phrase_id, ticks=ticks, transpose=transpose, default=False), ) + carried = matcher.table[phrase_id].default + if NO_DEFAULT_COUNT < carried <= ticks: + shortest.relax( + position, + position + carried, + cost + phrase_size(phrase_id, transpose, default=True), + PhraseToken(phrase_id=phrase_id, ticks=carried, transpose=transpose, default=True), + ) def parse_plane( diff --git a/src/sampletones_player/compression/search.py b/src/sampletones_player/compression/search.py index 300208343..99aad6102 100644 --- a/src/sampletones_player/compression/search.py +++ b/src/sampletones_player/compression/search.py @@ -130,8 +130,8 @@ def _gain( costs = parses[occurrence.plane].costs parsed += costs[occurrence.position + length] - costs[occurrence.position] - stated = phrase_size(phrase_id, UNSHIFTED_OCCURRENCE_TRANSPOSE) - shifted = phrase_size(phrase_id, SHIFTED_OCCURRENCE_TRANSPOSE) + stated = phrase_size(phrase_id, UNSHIFTED_OCCURRENCE_TRANSPOSE, default=False) + shifted = phrase_size(phrase_id, SHIFTED_OCCURRENCE_TRANSPOSE, default=False) return parsed - stated - shifted * (len(occurrences) - 1) diff --git a/src/sampletones_player/compression/tokens/phrase.py b/src/sampletones_player/compression/tokens/phrase.py index 194e83e1e..237fb7604 100644 --- a/src/sampletones_player/compression/tokens/phrase.py +++ b/src/sampletones_player/compression/tokens/phrase.py @@ -8,14 +8,16 @@ class PhraseToken: """The plane plays a phrase from the table, shifted by ``transpose``, for ``ticks`` ticks. A count past the phrase's own length holds its final value onward, the way a note whose - envelope has finished keeps sounding, and a count short of it cuts the note off. + envelope has finished keeps sounding, and a count short of it cuts the note off. A token + playing the count the phrase itself carries states none, which is a byte it spares. """ phrase_id: int ticks: int transpose: int + default: bool @property def size(self) -> int: """The bytes the token takes.""" - return phrase_size(self.phrase_id, self.transpose) + return phrase_size(self.phrase_id, self.transpose, default=self.default) diff --git a/src/sampletones_player/compression/tokens/sizes.py b/src/sampletones_player/compression/tokens/sizes.py index 80d946333..a628efa23 100644 --- a/src/sampletones_player/compression/tokens/sizes.py +++ b/src/sampletones_player/compression/tokens/sizes.py @@ -17,19 +17,27 @@ def literal_size(length: int) -> int: return OPCODE_SIZE + length -def phrase_size(phrase_id: int, transpose: int) -> int: +def phrase_size( + phrase_id: int, + transpose: int, + *, + default: bool, +) -> int: """The bytes a phrase token takes. The opcode carries the phrase's id where the id is one of the cheap ones, and a further byte - names it beyond those. A count byte follows, and a shifted phrase carries the shift as well. + names it beyond those. A token stating a count of its own carries it in a byte, and one + playing the phrase's own count carries none. A shifted phrase carries the shift as well. Args: phrase_id: Position the phrase takes in the table. transpose: The shift every byte of the phrase is played at. + default: Whether the token plays the count the phrase carries. Returns: int: The bytes the token takes. """ escape = PHRASE_ESCAPE_SIZE if phrase_id >= PHRASE_ID_ESCAPE else 0 + count = 0 if default else PHRASE_COUNT_SIZE shift = TRANSPOSE_SIZE if transpose else 0 - return OPCODE_SIZE + PHRASE_COUNT_SIZE + escape + shift + return OPCODE_SIZE + count + escape + shift diff --git a/src/sampletones_player/compression/tokens/span.py b/src/sampletones_player/compression/tokens/span.py index bc67a7f5a..587b361bf 100644 --- a/src/sampletones_player/compression/tokens/span.py +++ b/src/sampletones_player/compression/tokens/span.py @@ -1,10 +1,13 @@ from typing import NamedTuple +from sampletones_player.compression.dictionary.table import PhraseTable from sampletones_player.specification.compression import ( + DEFAULT_COUNT_FLAG, OPCODE_SIZE, PHRASE_COUNT_SIZE, PHRASE_ESCAPE_SIZE, PHRASE_ID_ESCAPE, + PHRASE_ID_MASK, TOKEN_OPERAND_MASK, TOKEN_TAG_MASK, TRANSPOSE_SIZE, @@ -28,26 +31,39 @@ class TokenSpan(NamedTuple): ticks: int -def _phrase_span(data: bytes, position: int, *, transposed: bool) -> TokenSpan: - named = data[position] & TOKEN_OPERAND_MASK +def _phrase_span( + data: bytes, + position: int, + table: PhraseTable, + *, + transposed: bool, +) -> TokenSpan: + named = data[position] & PHRASE_ID_MASK + stated = not data[position] & DEFAULT_COUNT_FLAG escape = PHRASE_ESCAPE_SIZE if named == PHRASE_ID_ESCAPE else 0 - count = position + OPCODE_SIZE + escape + after = position + OPCODE_SIZE + escape + phrase_id = data[position + OPCODE_SIZE] if escape else named shift = TRANSPOSE_SIZE if transposed else 0 return TokenSpan( - size=OPCODE_SIZE + escape + PHRASE_COUNT_SIZE + shift, - ticks=data[count] + 1, + size=OPCODE_SIZE + escape + (PHRASE_COUNT_SIZE if stated else 0) + shift, + ticks=data[after] + 1 if stated else table[phrase_id].default, ) -def token_span(data: bytes, position: int) -> TokenSpan: +def token_span( + data: bytes, + position: int, + table: PhraseTable, +) -> TokenSpan: """Reads the token written at ``position``, answering what it takes and what it covers. Args: data: The plane's token stream. position: The byte the token's opcode lies at. + table: The dictionary the tokens name, for the count a phrase carries. Returns: - TokenSpan: The bytes the token takes and the ticks it covers. + TokenSpan: The bytes the token takes and the values it covers. """ operand = data[position] & TOKEN_OPERAND_MASK match TokenTag(data[position] & TOKEN_TAG_MASK): @@ -56,6 +72,6 @@ def token_span(data: bytes, position: int) -> TokenSpan: case TokenTag.LITERAL: return TokenSpan(size=OPCODE_SIZE + operand + 1, ticks=operand + 1) case TokenTag.PHRASE: - return _phrase_span(data, position, transposed=False) + return _phrase_span(data, position, table, transposed=False) case TokenTag.TRANSPOSED_PHRASE: - return _phrase_span(data, position, transposed=True) + return _phrase_span(data, position, table, transposed=True) diff --git a/src/sampletones_player/driver/binary/driver.bin b/src/sampletones_player/driver/binary/driver.bin index f21e82732cebd1aa8102cb59e18f1ce1807b42ed..b998206d46cf4875c8052d88775c6a3bcfba0cc1 100644 GIT binary patch delta 293 zcmWlQzfZzI9L0OrQd&g}QLp8Pv+}#hr$f>-Mch0dah9Q@|;qp%MrsSgJW* zYFt~?Xw=DzTwPRYbez`de4Urm#>R>QK|xL>1XUu?1P%m}Z`c!Sc!U8w+36t-3GAfJ z_GwvpA2}Eksxg<*3JgQH%73e9Lp@POeV%nR~NIZeC!g z`0|7#WHvQInphbDBeTGqC{rV`B}}SzMnasBhz+oPup06U@5}6twLbTu@}co7cf|y* gH)V6@?aHRq=MUbisf^40pleK&m&&LA$9{18ABkjY*#H0l delta 277 zcmbQn*2XqLi?L~ESeCM!TG zrb>JhyD+(jNka5y(^3n~^PC2~EDlS3S_K!eEOk=2Ie90Ov>Z?-OOqcc!{e~DM6;ih Xp_k7g!GL1{LyO=FpJ$UlF{uCmU9DvF diff --git a/src/sampletones_player/nsf/layout.py b/src/sampletones_player/nsf/layout.py index 754f384f0..bc1cdd944 100644 --- a/src/sampletones_player/nsf/layout.py +++ b/src/sampletones_player/nsf/layout.py @@ -7,6 +7,7 @@ from sampletones_player.compression.planes.order import PlaneOrder from sampletones_player.song import Song from sampletones_player.specification.compression import ( + PHRASE_DEFAULT_SIZE, PHRASE_LENGTH_SIZE, PHRASE_TABLE_COUNT_SIZE, PHRASE_TABLE_ENTRY_SIZE, @@ -78,7 +79,7 @@ def of(cls, song: Song) -> SongLayout: """ phrases = song.planes.phrases streams = song.planes.streams - body_sizes = [PHRASE_LENGTH_SIZE + phrase.length for phrase in phrases.phrases] + body_sizes = [PHRASE_LENGTH_SIZE + PHRASE_DEFAULT_SIZE + phrase.length for phrase in phrases.phrases] stream_sizes = [len(stream) for stream in streams] timer_table = SONG_HEADER_SIZE diff --git a/src/sampletones_player/nsf/song.py b/src/sampletones_player/nsf/song.py index ba76470fc..84826f084 100644 --- a/src/sampletones_player/nsf/song.py +++ b/src/sampletones_player/nsf/song.py @@ -1,12 +1,17 @@ +from typing import Final + from sampletones_core.formats.binary import BinaryWriter from sampletones_player.compression.dictionary.table import PhraseTable from sampletones_player.compression.pitch import PitchTable from sampletones_player.compression.planes.order import PlaneOrder from sampletones_player.nsf.layout import SongLayout from sampletones_player.song import Song +from sampletones_player.specification.compression import NO_DEFAULT_COUNT from sampletones_player.specification.song import MAX_BLOCK_OFFSET, NO_LOOP from sampletones_shared.exceptions import SongTooLargeError +STATED_BEYOND_THE_FIRST: Final[int] = 1 + def _write_header( writer: BinaryWriter, @@ -42,6 +47,7 @@ def _write_phrase_table( for phrase in phrases.phrases: writer.write_uint8(phrase.length) + writer.write_uint8(max(phrase.default - STATED_BEYOND_THE_FIRST, NO_DEFAULT_COUNT)) writer.write_bytes(phrase.body) diff --git a/src/sampletones_player/specification/compression.py b/src/sampletones_player/specification/compression.py index 75ea8716f..c2a96c2df 100644 --- a/src/sampletones_player/specification/compression.py +++ b/src/sampletones_player/specification/compression.py @@ -18,6 +18,10 @@ class TokenTag(IntEnum): LITERAL: The plane takes the bytes that follow, one per tick. PHRASE: The plane plays a phrase from the table at the pitch it was stored at. TRANSPOSED_PHRASE: The plane plays a phrase shifted by the signed byte that follows. + + A phrase opcode spends the top bit of its operand on whether the token states a count of its + own or plays the count the phrase itself carries, so the ids it names outright are the lower + half of what a hold or a literal counts. """ HOLD = 0x00 @@ -38,7 +42,9 @@ class TokenTag(IntEnum): MAX_LITERAL_BYTES: Final[int] = TOKEN_OPERAND_MASK + 1 MAX_PHRASE_TICKS: Final[int] = BYTE_VALUES -PHRASE_ID_ESCAPE: Final[int] = TOKEN_OPERAND_MASK +DEFAULT_COUNT_FLAG: Final[int] = (TOKEN_OPERAND_MASK + 1) >> 1 +PHRASE_ID_MASK: Final[int] = DEFAULT_COUNT_FLAG - 1 +PHRASE_ID_ESCAPE: Final[int] = PHRASE_ID_MASK CHEAP_PHRASE_IDS: Final[int] = PHRASE_ID_ESCAPE MAX_PHRASE_IDS: Final[int] = MAX_BYTE_VALUE MAX_PHRASE_LENGTH: Final[int] = MAX_BYTE_VALUE @@ -46,6 +52,8 @@ class TokenTag(IntEnum): PHRASE_TABLE_COUNT_SIZE: Final[int] = ceil(MAX_PHRASE_IDS.bit_length() / BITS_PER_BYTE) PHRASE_TABLE_ENTRY_SIZE: Final[int] = WORD_SIZE PHRASE_LENGTH_SIZE: Final[int] = 1 +PHRASE_DEFAULT_SIZE: Final[int] = 1 +NO_DEFAULT_COUNT: Final[int] = 0 INITIAL_PLANE_VALUE: Final[int] = 0 BEND_FLAG: Final[int] = 0x80 diff --git a/src/sampletones_tools/codec/study/accounting/dictionary.py b/src/sampletones_tools/codec/study/accounting/dictionary.py index 1307bd4b2..03d83e377 100644 --- a/src/sampletones_tools/codec/study/accounting/dictionary.py +++ b/src/sampletones_tools/codec/study/accounting/dictionary.py @@ -1,16 +1,12 @@ -from collections import Counter -from typing import Dict, Final, Iterable +from typing import Final from sampletones_player.compression.dictionary.table import PhraseTable -from sampletones_player.specification.compression import PHRASE_COUNT_SIZE from sampletones_tools.codec.study.accounting.finding import NOTHING, Finding from sampletones_tools.codec.study.accounting.runs import runs -from sampletones_tools.codec.study.accounting.tokens import ReadToken REPEATED: Final[int] = 2 MIN_RLE_RUN: Final[int] = 3 RLE_RUN_SIZE: Final[int] = 2 -DEFAULT_COUNT_SIZE: Final[int] = 1 def plateaus(table: PhraseTable) -> Finding: @@ -32,35 +28,3 @@ def plateaus(table: PhraseTable) -> Finding: finding += Finding(run - 1, max(0, run - RLE_RUN_SIZE) if run >= MIN_RLE_RUN else 0) return finding - - -def default_counts( - table: PhraseTable, - tokens: Iterable[ReadToken], -) -> Finding: - """What a default count stored with each phrase would spare on the count bytes (H4). - - Every phrase token carries the ticks it plays for. A phrase played mostly for one length - could state that length once in its body, and a token playing it that long could leave the - count byte out. - - Args: - table: The dictionary. - tokens: Every token of every plane. - - Returns: - Finding: The count bytes phrase tokens carry, and the bytes a default would spare. - """ - counts: Dict[int, Counter[int]] = {phrase_id: Counter() for phrase_id in range(len(table))} - for token in tokens: - if token.phrase_id is not None: - counts[token.phrase_id][token.ticks] += 1 - - targeted = PHRASE_COUNT_SIZE * sum(sum(counter.values()) for counter in counts.values()) - saving = 0 - for counter in counts.values(): - if counter: - (_, modal), *_ = counter.most_common(1) - saving += max(0, PHRASE_COUNT_SIZE * modal - DEFAULT_COUNT_SIZE) - - return Finding(targeted, saving) diff --git a/src/sampletones_tools/codec/study/accounting/rows.py b/src/sampletones_tools/codec/study/accounting/rows.py index ff0e9c832..b7c31b957 100644 --- a/src/sampletones_tools/codec/study/accounting/rows.py +++ b/src/sampletones_tools/codec/study/accounting/rows.py @@ -4,7 +4,7 @@ from sampletones_player.compression.compressed import CompressedPlanes from sampletones_player.compression.planes.order import PlaneOrder from sampletones_tools.codec.study.accounting.coincident import coincident_starts -from sampletones_tools.codec.study.accounting.dictionary import default_counts, plateaus +from sampletones_tools.codec.study.accounting.dictionary import plateaus from sampletones_tools.codec.study.accounting.finding import NOTHING, Finding from sampletones_tools.codec.study.accounting.fixed import fixed_overheads from sampletones_tools.codec.study.accounting.pairs import ramps_in_literals, set_holds @@ -16,7 +16,6 @@ ("H1", "hold chains"), ("H2", "plateaus in bodies"), ("H3", "set-then-hold"), - ("H4", "default counts"), ("H5", "coincident starts"), ("H6", "ramps in literals"), ("H7", "fixed tables"), @@ -114,18 +113,16 @@ def account( AccountingRow: The shares and the findings. """ tokens: Dict[str, Tuple[ReadToken, ...]] = { - name: read_tokens(stream) for name, stream in zip(PlaneOrder.names(), compressed.streams) + name: read_tokens(stream, compressed.phrases) for name, stream in zip(PlaneOrder.names(), compressed.streams) } planes = tuple( plane_shares(name, plane, tokens[name]) for name, plane in zip(PlaneOrder.names(), measurement.song.planes.planes) ) - every_token = [token for name in PlaneOrder.names() for token in tokens[name]] findings = ( sum((plane.hold_chains for plane in planes), NOTHING), plateaus(compressed.phrases), sum((set_holds(tokens[name]) for name in PlaneOrder.names()), NOTHING), - default_counts(compressed.phrases, every_token), coincident_starts(tokens), sum((ramps_in_literals(tokens[name]) for name in PlaneOrder.names()), NOTHING), fixed_overheads(measurement.song, compressed).finding, diff --git a/src/sampletones_tools/codec/study/accounting/tokens.py b/src/sampletones_tools/codec/study/accounting/tokens.py index 73deed04f..90734a276 100644 --- a/src/sampletones_tools/codec/study/accounting/tokens.py +++ b/src/sampletones_tools/codec/study/accounting/tokens.py @@ -1,11 +1,14 @@ from typing import List, NamedTuple, Optional, Tuple +from sampletones_player.compression.dictionary.table import PhraseTable from sampletones_player.compression.tokens.span import token_span from sampletones_player.specification.compression import ( + DEFAULT_COUNT_FLAG, OPCODE_SIZE, PHRASE_COUNT_SIZE, PHRASE_ESCAPE_SIZE, PHRASE_ID_ESCAPE, + PHRASE_ID_MASK, TOKEN_OPERAND_MASK, TOKEN_TAG_MASK, TokenTag, @@ -47,20 +50,27 @@ def _phrase_operands( transposed: bool, ) -> Tuple[int, int]: after = position + OPCODE_SIZE - phrase_id = operand - if operand == PHRASE_ID_ESCAPE: + phrase_id = operand & PHRASE_ID_MASK + if phrase_id == PHRASE_ID_ESCAPE: phrase_id = stream[after] after += PHRASE_ESCAPE_SIZE - transpose = stream[after + PHRASE_COUNT_SIZE] if transposed else 0 + if not operand & DEFAULT_COUNT_FLAG: + after += PHRASE_COUNT_SIZE + + transpose = stream[after] if transposed else 0 return phrase_id, transpose -def read_tokens(stream: bytes) -> Tuple[ReadToken, ...]: +def read_tokens( + stream: bytes, + table: PhraseTable, +) -> Tuple[ReadToken, ...]: """Reads a plane's stream back as the tokens it was written from. Args: stream: The plane's token stream. + table: The dictionary the tokens name. Returns: Tuple[ReadToken, ...]: The tokens in the order they are read. @@ -69,7 +79,7 @@ def read_tokens(stream: bytes) -> Tuple[ReadToken, ...]: position = 0 tick = 0 while position < len(stream): - span = token_span(stream, position) + span = token_span(stream, position, table) tag = TokenTag(stream[position] & TOKEN_TAG_MASK) operand = stream[position] & TOKEN_OPERAND_MASK payload = b"" diff --git a/src/sampletones_tools/codec/study/sandbox/costs.py b/src/sampletones_tools/codec/study/sandbox/costs.py index 23d3f1703..d05045678 100644 --- a/src/sampletones_tools/codec/study/sandbox/costs.py +++ b/src/sampletones_tools/codec/study/sandbox/costs.py @@ -7,13 +7,12 @@ CHEAP_PHRASE_IDS, OPCODE_SIZE, PHRASE_COUNT_SIZE, + PHRASE_DEFAULT_SIZE, PHRASE_ESCAPE_SIZE, TRANSPOSE_SIZE, ) VALUE_SIZE: Final[int] = 1 -DEFAULT_COUNT_SIZE: Final[int] = 1 -DEFAULT_FLAG_BITS: Final[int] = 1 @dataclass(frozen=True) @@ -87,7 +86,6 @@ def dictionary(self, table: PhraseTable) -> int: phrase_escape=PHRASE_ESCAPE_SIZE, transpose=TRANSPOSE_SIZE, operands=CHEAP_PHRASE_IDS, - default_entry=0, + default_entry=PHRASE_DEFAULT_SIZE, ) SET_HOLD_BOUND: Final[int] = OPCODE_SIZE + VALUE_SIZE -DEFAULT_COUNT_OPERANDS: Final[int] = (CHEAP_PHRASE_IDS + 1) // (1 << DEFAULT_FLAG_BITS) - 1 diff --git a/src/sampletones_tools/codec/study/sandbox/grammar.py b/src/sampletones_tools/codec/study/sandbox/grammar.py index 1827e2924..81cb010e8 100644 --- a/src/sampletones_tools/codec/study/sandbox/grammar.py +++ b/src/sampletones_tools/codec/study/sandbox/grammar.py @@ -35,6 +35,6 @@ class Grammar: start_hold=False, wide_hold=False, set_hold=False, - default_counts=False, + default_counts=True, costs=PRODUCTION_COSTS, ) diff --git a/src/sampletones_tools/codec/study/sandbox/reference.py b/src/sampletones_tools/codec/study/sandbox/reference.py index 5912aa9d9..4f080fea5 100644 --- a/src/sampletones_tools/codec/study/sandbox/reference.py +++ b/src/sampletones_tools/codec/study/sandbox/reference.py @@ -14,7 +14,6 @@ from sampletones_tools.codec.study.corpus.song import StudySong from sampletones_tools.codec.study.sandbox.context import PlaneContext from sampletones_tools.codec.study.sandbox.costs import Costs -from sampletones_tools.codec.study.sandbox.defaults import no_defaults from sampletones_tools.codec.study.sandbox.grammar import BASELINE_GRAMMAR, Grammar from sampletones_tools.codec.study.sandbox.parse import StudyParse, parse_plane from sampletones_tools.codec.study.sandbox.verify import verify_baseline @@ -121,7 +120,8 @@ def reference( ) cache = MatchCache(PlaneIndex.from_plane(plane) for plane in planes if plane) table = compressed.phrases - baseline = plane_parses(planes, cache, table, BASELINE_GRAMMAR, no_defaults(len(table))) + carried = tuple(phrase.default for phrase in table.phrases) + baseline = plane_parses(planes, cache, table, BASELINE_GRAMMAR, carried) verify_baseline(baseline, compressed) return Reference( song=song, diff --git a/src/sampletones_tools/codec/study/variants/sandbox.py b/src/sampletones_tools/codec/study/variants/sandbox.py index bb63a6769..6cbaadca6 100644 --- a/src/sampletones_tools/codec/study/variants/sandbox.py +++ b/src/sampletones_tools/codec/study/variants/sandbox.py @@ -4,8 +4,6 @@ from sampletones_tools.codec.study.corpus.song import StudySong from sampletones_tools.codec.study.measure import Encoder, Encoding from sampletones_tools.codec.study.sandbox.costs import ( - DEFAULT_COUNT_OPERANDS, - DEFAULT_COUNT_SIZE, PRODUCTION_COSTS, SET_HOLD_BOUND, Costs, @@ -17,7 +15,6 @@ WIDE_HOLD: Final[str] = "H1" SET_HOLD: Final[str] = "H3" -DEFAULT_COUNT: Final[str] = "H4" HOLD_EDGES: Final[str] = "H9" DRIVER_UNCHANGED: Final[str] = "driver unchanged" WIDE_HOLD_NOTE: Final[str] = ( @@ -30,17 +27,8 @@ SET_HOLD_BOUND_NOTE: Final[str] = ( "a two-byte set-hold needs an opcode of its own, so this bounds what any reallocation of the opcode space reaches" ) -DEFAULT_COUNT_NOTE: Final[str] = ( - "one operand bit says the count is the phrase's own, leaving 31 cheap ids; each table entry " - "grows by a byte, read once when a phrase is set" -) COMBINED_NOTE: Final[str] = "every change the free escape carries at once, the escape counting 31 blocks and 31 ticks" -DEFAULT_COUNT_COSTS: Final[Costs] = replace( - PRODUCTION_COSTS, - operands=DEFAULT_COUNT_OPERANDS, - default_entry=DEFAULT_COUNT_SIZE, -) SET_HOLD_BOUND_COSTS: Final[Costs] = replace( PRODUCTION_COSTS, set_hold=SET_HOLD_BOUND, @@ -108,23 +96,9 @@ class GrammarVariant(NamedTuple): note=SET_HOLD_BOUND_NOTE, grammar=replace(BASELINE_GRAMMAR, set_hold=True, costs=SET_HOLD_BOUND_COSTS), ), - GrammarVariant( - name="default-count", - hypothesis=DEFAULT_COUNT, - kind=VariantKind.FORMAT, - note=DEFAULT_COUNT_NOTE, - grammar=replace(BASELINE_GRAMMAR, default_counts=True, costs=DEFAULT_COUNT_COSTS), - ), - GrammarVariant( - name="wide-hold+default-count", - hypothesis=f"{WIDE_HOLD}+{DEFAULT_COUNT}", - kind=VariantKind.FORMAT, - note=f"{WIDE_HOLD_NOTE}; {DEFAULT_COUNT_NOTE}", - grammar=replace(BASELINE_GRAMMAR, wide_hold=True, default_counts=True, costs=DEFAULT_COUNT_COSTS), - ), GrammarVariant( name="escape-grammar", - hypothesis=f"{WIDE_HOLD}+{SET_HOLD}+{DEFAULT_COUNT}+{HOLD_EDGES}", + hypothesis=f"{WIDE_HOLD}+{SET_HOLD}+{HOLD_EDGES}", kind=VariantKind.FORMAT, note=COMBINED_NOTE, grammar=replace( @@ -132,8 +106,6 @@ class GrammarVariant(NamedTuple): start_hold=True, wide_hold=True, set_hold=True, - default_counts=True, - costs=DEFAULT_COUNT_COSTS, ), ), ) diff --git a/src/sampletones_tools/player/assembly/include/song.inc b/src/sampletones_tools/player/assembly/include/song.inc index 306b6c188..ee020b183 100644 --- a/src/sampletones_tools/player/assembly/include/song.inc +++ b/src/sampletones_tools/player/assembly/include/song.inc @@ -33,10 +33,13 @@ TAG_PHRASE = $80 TAG_TRANSPOSED_PHRASE = $C0 OPCODE_SIZE = 1 -PHRASE_ID_ESCAPE = $3F +DEFAULT_COUNT_FLAG = $20 +PHRASE_ID_MASK = $1F +PHRASE_ID_ESCAPE = $1F PHRASE_TABLE_COUNT_SIZE = 1 PHRASE_TABLE_ENTRY_SIZE = 2 PHRASE_LENGTH_SIZE = 1 +PHRASE_DEFAULT_SIZE = 1 COUNT_STEP = $10 COUNT_SHIFT = 4 diff --git a/src/sampletones_tools/player/assembly/source/channels.s b/src/sampletones_tools/player/assembly/source/channels.s index 6b9b88a76..eb8c15856 100644 --- a/src/sampletones_tools/player/assembly/source/channels.s +++ b/src/sampletones_tools/player/assembly/source/channels.s @@ -33,6 +33,7 @@ timer_high_shadows: .res TRIANGLE_REGISTERS + 1 .assert OPCODE_SIZE = 1, error, "a token's operands follow its opcode by one byte" .assert PHRASE_TABLE_ENTRY_SIZE = 2, error, "a table entry is reached by one doubling" .assert PHRASE_LENGTH_SIZE = 1, error, "a phrase body follows its length by one byte" +.assert DEFAULT_COUNT_FLAG = PHRASE_ID_MASK + 1, error, "the count flag stands above every id an opcode names" .assert >song_data <> ABSENT_PAGE, lderror, "a song loaded in page zero reads as an absent plane" .assert ABSENT_STREAM = $FFFF, error, "an absent stream is told apart by both its bytes reading $FF" .assert BEND_FLAG = $80, error, "a value's flag is read as the sign bit" @@ -284,16 +285,20 @@ fetch_token: @plays_phrase: lda opcode - and #TOKEN_OPERAND_MASK + and #PHRASE_ID_MASK cmp #PHRASE_ID_ESCAPE bne @named lda (pointer),y iny @named: jsr set_phrase + lda opcode + and #DEFAULT_COUNT_FLAG + bne @shifts lda (pointer),y iny sta plane_state + PLANE_TOKEN_TICKS,x +@shifts: lda #$00 sta plane_state + PLANE_SHIFT,x bit opcode @@ -349,9 +354,15 @@ set_phrase: ldy #$00 lda (entry),y sta plane_state + PLANE_PHRASE_TICKS,x + iny + lda (entry),y + sta plane_state + PLANE_TOKEN_TICKS,x - inc plane_state + PLANE_PHRASE,x - bne @body + clc + lda plane_state + PLANE_PHRASE,x + adc #(PHRASE_LENGTH_SIZE + PHRASE_DEFAULT_SIZE) + sta plane_state + PLANE_PHRASE,x + bcc @body inc plane_state + PLANE_PHRASE + 1,x @body: pla diff --git a/tests/unit/sampletones_player/compression/dictionary/test_phrase.py b/tests/unit/sampletones_player/compression/dictionary/test_phrase.py index fc945a7ac..255a0af71 100644 --- a/tests/unit/sampletones_player/compression/dictionary/test_phrase.py +++ b/tests/unit/sampletones_player/compression/dictionary/test_phrase.py @@ -4,6 +4,7 @@ from sampletones_player.compression.dictionary.phrase import Phrase from sampletones_player.specification.compression import ( MAX_PHRASE_LENGTH, + PHRASE_DEFAULT_SIZE, PHRASE_LENGTH_SIZE, PHRASE_TABLE_ENTRY_SIZE, ) @@ -22,8 +23,8 @@ def test_a_shifted_phrase_keeps_the_steps_of_the_one_it_was_stored_from(self) -> def test_a_phrase_covers_the_ticks_its_body_states(self) -> None: assert phrase(1, 2, 3).length == 3 - def test_a_phrase_costs_its_entry_its_length_and_its_body(self) -> None: - assert phrase(1, 2, 3).size == PHRASE_TABLE_ENTRY_SIZE + PHRASE_LENGTH_SIZE + 3 + def test_a_phrase_costs_its_entry_its_length_its_count_and_its_body(self) -> None: + assert phrase(1, 2, 3).size == PHRASE_TABLE_ENTRY_SIZE + PHRASE_LENGTH_SIZE + PHRASE_DEFAULT_SIZE + 3 def test_a_body_reaching_past_a_length_byte_is_refused(self) -> None: with pytest.raises(ValidationError): diff --git a/tests/unit/sampletones_player/compression/dictionary/test_prune.py b/tests/unit/sampletones_player/compression/dictionary/test_prune.py index 479ce6acd..0c2a3709c 100644 --- a/tests/unit/sampletones_player/compression/dictionary/test_prune.py +++ b/tests/unit/sampletones_player/compression/dictionary/test_prune.py @@ -1,21 +1,26 @@ +from collections import defaultdict +from typing import Dict, Final + from sampletones_player.compression.dictionary.prune import prune from sampletones_player.compression.dictionary.table import phrase_table from tests.unit.sampletones_player.compression.dictionary.phrases import phrase +NO_COUNTS: Final[Dict[int, int]] = defaultdict(int) + class TestPruningKeepsWhatPaysForItself: """A phrase earns its place by sparing more bytes than its own entry takes.""" def test_a_phrase_no_token_names_is_dropped(self) -> None: table = phrase_table((phrase(1, 2), phrase(3, 4))) - pruned = prune(table, {0: 2, 1: 0}, {0: 100, 1: 100}) + pruned = prune(table, {0: 2, 1: 0}, {0: 100, 1: 100}, NO_COUNTS) assert pruned.phrases == (phrase(1, 2),) def test_a_phrase_sparing_less_than_its_entry_is_dropped(self) -> None: table = phrase_table((phrase(1, 2),)) - assert prune(table, {0: 1}, {0: phrase(1, 2).size}).phrases == () + assert prune(table, {0: 1}, {0: phrase(1, 2).size}, NO_COUNTS).phrases == () def test_the_phrases_named_most_take_the_cheap_ids(self) -> None: table = phrase_table((phrase(1, 2), phrase(3, 4))) - pruned = prune(table, {0: 1, 1: 9}, {0: 100, 1: 100}) + pruned = prune(table, {0: 1, 1: 9}, {0: 100, 1: 100}, NO_COUNTS) assert pruned.phrases == (phrase(3, 4), phrase(1, 2)) diff --git a/tests/unit/sampletones_player/compression/parse/test_plane.py b/tests/unit/sampletones_player/compression/parse/test_plane.py index ebbadd425..6c1abf2f8 100644 --- a/tests/unit/sampletones_player/compression/parse/test_plane.py +++ b/tests/unit/sampletones_player/compression/parse/test_plane.py @@ -85,12 +85,12 @@ def test_a_plane_the_codec_finds_nothing_in_spells_itself_out(self) -> None: def test_a_figure_the_dictionary_holds_reaches_the_stream_as_a_phrase(self) -> None: parse = parsed(MOTIF, (Phrase(body=MOTIF),)) - assert parse.tokens == (PhraseToken(phrase_id=0, ticks=len(MOTIF), transpose=0),) + assert parse.tokens == (PhraseToken(phrase_id=0, ticks=len(MOTIF), transpose=0, default=False),) def test_the_same_figure_played_higher_names_the_same_phrase(self) -> None: higher = bytes(value + 7 for value in MOTIF) parse = parsed(higher, (Phrase(body=MOTIF),)) - assert parse.tokens == (PhraseToken(phrase_id=0, ticks=len(MOTIF), transpose=7),) + assert parse.tokens == (PhraseToken(phrase_id=0, ticks=len(MOTIF), transpose=7, default=False),) def test_a_phrase_the_layer_switches_off_is_spelled_out_instead(self) -> None: parse = parsed(MOTIF, (Phrase(body=MOTIF),), options=LITERALS_ONLY) diff --git a/tests/unit/sampletones_player/compression/test_encode.py b/tests/unit/sampletones_player/compression/test_encode.py index ecd05679a..787710ef8 100644 --- a/tests/unit/sampletones_player/compression/test_encode.py +++ b/tests/unit/sampletones_player/compression/test_encode.py @@ -52,19 +52,23 @@ def test_a_literal_carries_its_length_inside_its_opcode(self) -> None: assert emit((LiteralToken(values=bytes((0x10, 0x20))),)) == bytes((TokenTag.LITERAL | 1, 0x10, 0x20)) def test_a_phrase_carries_a_cheap_id_inside_its_opcode(self) -> None: - token = PhraseToken(phrase_id=2, ticks=5, transpose=0) + token = PhraseToken(phrase_id=2, ticks=5, transpose=0, default=False) assert emit((token,)) == bytes((TokenTag.PHRASE | 2, 4)) def test_a_shifted_phrase_states_the_shift_after_the_count(self) -> None: - token = PhraseToken(phrase_id=2, ticks=5, transpose=0xFD) + token = PhraseToken(phrase_id=2, ticks=5, transpose=0xFD, default=False) assert emit((token,)) == bytes((TokenTag.TRANSPOSED_PHRASE | 2, 4, 0xFD)) def test_a_phrase_beyond_the_cheap_ids_names_itself_in_the_byte_that_follows(self) -> None: - token = PhraseToken(phrase_id=200, ticks=MAX_PHRASE_TICKS, transpose=0) + token = PhraseToken(phrase_id=200, ticks=MAX_PHRASE_TICKS, transpose=0, default=False) assert emit((token,)) == bytes((TokenTag.PHRASE | PHRASE_ID_ESCAPE, 200, MAX_PHRASE_TICKS - 1)) def test_a_stream_takes_the_bytes_the_parse_counted(self) -> None: - tokens = (LiteralToken(values=MOTIF), HoldToken(ticks=4), PhraseToken(phrase_id=1, ticks=2, transpose=3)) + tokens = ( + LiteralToken(values=MOTIF), + HoldToken(ticks=4), + PhraseToken(phrase_id=1, ticks=2, transpose=3, default=False), + ) assert len(emit(tokens)) == sum(token.size for token in tokens) @@ -114,7 +118,7 @@ def test_the_figure_played_most_takes_the_cheapest_id(self) -> None: options=SEEDED, boundaries=NO_BOUNDARIES, ) - assert compressed.phrases[0] == Phrase(body=MOTIF) + assert compressed.phrases[0].body == MOTIF def test_a_song_re_entered_at_a_boundary_still_plays_back_whole(self) -> None: planes = song_planes(TIMBRE * REPEATS, MOTIF * REPEATS) diff --git a/tests/unit/sampletones_player/compression/test_entries.py b/tests/unit/sampletones_player/compression/test_entries.py index 9e31676c1..b784db58e 100644 --- a/tests/unit/sampletones_player/compression/test_entries.py +++ b/tests/unit/sampletones_player/compression/test_entries.py @@ -2,6 +2,7 @@ import pytest +from sampletones_player.compression.dictionary.table import PhraseTable, phrase_table from sampletones_player.compression.encode import emit from sampletones_player.compression.entries import stream_entry from sampletones_player.compression.tokens.hold import HoldToken @@ -9,6 +10,7 @@ from sampletones_player.compression.tokens.phrase import PhraseToken FIRST_TICK: Final[int] = 0 +DICTIONARY: Final[PhraseTable] = phrase_table(()) PHRASE_ID: Final[int] = 2 SHIFT: Final[int] = 7 @@ -18,25 +20,27 @@ class TestWhereAStreamIsReEntered: def test_the_first_tick_is_the_streams_own_first_byte(self) -> None: stream = emit([LiteralToken(values=b"\x01\x02"), HoldToken(ticks=3)]) - assert stream_entry(stream, FIRST_TICK) == 0 + assert stream_entry(stream, FIRST_TICK, DICTIONARY) == 0 def test_a_tick_a_token_starts_answers_with_that_tokens_byte(self) -> None: literal = LiteralToken(values=b"\x01\x02") - stream = emit([literal, HoldToken(ticks=3), PhraseToken(phrase_id=PHRASE_ID, ticks=4, transpose=0)]) - assert stream_entry(stream, literal.ticks) == literal.size + stream = emit( + [literal, HoldToken(ticks=3), PhraseToken(phrase_id=PHRASE_ID, ticks=4, transpose=0, default=False)] + ) + assert stream_entry(stream, literal.ticks, DICTIONARY) == literal.size def test_a_tick_further_in_walks_past_every_token_before_it(self) -> None: tokens = [ LiteralToken(values=b"\x01\x02"), HoldToken(ticks=3), - PhraseToken(phrase_id=PHRASE_ID, ticks=4, transpose=SHIFT), + PhraseToken(phrase_id=PHRASE_ID, ticks=4, transpose=SHIFT, default=False), ] stream = emit(tokens) covered = sum(token.ticks for token in tokens[:2]) - assert stream_entry(stream, covered) == sum(token.size for token in tokens[:2]) + assert stream_entry(stream, covered, DICTIONARY) == sum(token.size for token in tokens[:2]) def test_a_tick_a_token_spans_is_refused(self) -> None: """A stream re-entered mid-token would leave the driver reading operands as opcodes.""" stream = emit([HoldToken(ticks=8)]) with pytest.raises(ValueError, match="spans position"): - stream_entry(stream, 3) + stream_entry(stream, 3, DICTIONARY) diff --git a/tests/unit/sampletones_player/compression/tokens/test_phrase.py b/tests/unit/sampletones_player/compression/tokens/test_phrase.py index b60c280d8..515ef7d7a 100644 --- a/tests/unit/sampletones_player/compression/tokens/test_phrase.py +++ b/tests/unit/sampletones_player/compression/tokens/test_phrase.py @@ -51,5 +51,5 @@ def label(self) -> str: @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) def test_a_phrase_token_pays_for_the_id_and_the_shift_it_names(self, test_case: TestCase) -> None: - token = PhraseToken(phrase_id=test_case.phrase_id, ticks=1, transpose=test_case.transpose) - assert token.size == test_case.expected == phrase_size(test_case.phrase_id, test_case.transpose) + token = PhraseToken(phrase_id=test_case.phrase_id, ticks=1, transpose=test_case.transpose, default=False) + assert token.size == test_case.expected == phrase_size(test_case.phrase_id, test_case.transpose, default=False) diff --git a/tests/unit/sampletones_player/compression/tokens/test_span.py b/tests/unit/sampletones_player/compression/tokens/test_span.py index 6420af0ef..e362a86c0 100644 --- a/tests/unit/sampletones_player/compression/tokens/test_span.py +++ b/tests/unit/sampletones_player/compression/tokens/test_span.py @@ -3,6 +3,8 @@ import pytest +from sampletones_player.compression.dictionary.phrase import Phrase +from sampletones_player.compression.dictionary.table import PhraseTable, phrase_table from sampletones_player.compression.encode import emit from sampletones_player.compression.tokens.hold import HoldToken from sampletones_player.compression.tokens.literal import LiteralToken @@ -21,6 +23,10 @@ CHEAP_ID: Final[int] = 1 SHIFT: Final[int] = 5 PHRASE_PLAY_TICKS: Final[int] = 10 +CARRIED_TICKS: Final[int] = 6 +DICTIONARY: Final[PhraseTable] = phrase_table( + tuple(Phrase(body=bytes((value,)), default=CARRIED_TICKS) for value in range(PHRASE_ID_ESCAPE + 2)) +) class TestWhatAWrittenTokenTakesAndCovers(BaseTestSuite): @@ -50,17 +56,17 @@ def label(self) -> str: ), TestCase( name="phrase", - token=PhraseToken(phrase_id=CHEAP_ID, ticks=PHRASE_PLAY_TICKS, transpose=0), + token=PhraseToken(phrase_id=CHEAP_ID, ticks=PHRASE_PLAY_TICKS, transpose=0, default=False), expected=TokenSpan(size=2, ticks=PHRASE_PLAY_TICKS), ), TestCase( name="phrase-shifted", - token=PhraseToken(phrase_id=CHEAP_ID, ticks=PHRASE_PLAY_TICKS, transpose=SHIFT), + token=PhraseToken(phrase_id=CHEAP_ID, ticks=PHRASE_PLAY_TICKS, transpose=SHIFT, default=False), expected=TokenSpan(size=3, ticks=PHRASE_PLAY_TICKS), ), TestCase( name="phrase-escaped", - token=PhraseToken(phrase_id=PHRASE_ID_ESCAPE, ticks=PHRASE_PLAY_TICKS, transpose=0), + token=PhraseToken(phrase_id=PHRASE_ID_ESCAPE, ticks=PHRASE_PLAY_TICKS, transpose=0, default=False), expected=TokenSpan(size=3, ticks=PHRASE_PLAY_TICKS), ), TestCase( @@ -69,30 +75,31 @@ def label(self) -> str: phrase_id=PHRASE_ID_ESCAPE, ticks=PHRASE_PLAY_TICKS, transpose=SHIFT, + default=False, ), expected=TokenSpan(size=4, ticks=PHRASE_PLAY_TICKS), ), TestCase( name="phrase-longest", - token=PhraseToken(phrase_id=CHEAP_ID, ticks=MAX_PHRASE_TICKS, transpose=0), + token=PhraseToken(phrase_id=CHEAP_ID, ticks=MAX_PHRASE_TICKS, transpose=0, default=False), expected=TokenSpan(size=2, ticks=MAX_PHRASE_TICKS), ), ) @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) def test_the_span_states_what_the_token_takes(self, test_case: TestCase) -> None: - span = token_span(emit([test_case.token]), FIRST_TOKEN) + span = token_span(emit([test_case.token]), FIRST_TOKEN, DICTIONARY) assert span.size == test_case.expected.size @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) def test_the_span_states_what_the_token_covers(self, test_case: TestCase) -> None: - span = token_span(emit([test_case.token]), FIRST_TOKEN) + span = token_span(emit([test_case.token]), FIRST_TOKEN, DICTIONARY) assert span.ticks == test_case.expected.ticks @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) def test_the_span_reaches_the_byte_the_next_token_begins_at(self, test_case: TestCase) -> None: stream = emit([test_case.token, HoldToken(ticks=1)]) - assert token_span(stream, FIRST_TOKEN).size == len(stream) - 1 + assert token_span(stream, FIRST_TOKEN, DICTIONARY).size == len(stream) - 1 class TestWalkingAStream: @@ -102,12 +109,12 @@ def test_the_sizes_reach_the_streams_last_byte(self) -> None: tokens = ( LiteralToken(values=b"\x01\x02"), HoldToken(ticks=3), - PhraseToken(phrase_id=CHEAP_ID, ticks=4, transpose=SHIFT), + PhraseToken(phrase_id=CHEAP_ID, ticks=4, transpose=SHIFT, default=False), ) stream = emit(tokens) position = 0 for _ in tokens: - position += token_span(stream, position).size + position += token_span(stream, position, DICTIONARY).size assert position == len(stream) @@ -115,13 +122,13 @@ def test_the_ticks_reach_the_songs_length(self) -> None: tokens = ( LiteralToken(values=b"\x01\x02"), HoldToken(ticks=3), - PhraseToken(phrase_id=CHEAP_ID, ticks=4, transpose=0), + PhraseToken(phrase_id=CHEAP_ID, ticks=4, transpose=0, default=False), ) stream = emit(tokens) position = 0 covered = 0 for _ in tokens: - span = token_span(stream, position) + span = token_span(stream, position, DICTIONARY) covered += span.ticks position += span.size diff --git a/tests/unit/sampletones_player/nsf/test_layout.py b/tests/unit/sampletones_player/nsf/test_layout.py index 27f6c0f15..97002e18f 100644 --- a/tests/unit/sampletones_player/nsf/test_layout.py +++ b/tests/unit/sampletones_player/nsf/test_layout.py @@ -4,6 +4,7 @@ from sampletones_player.nsf.layout import SongLayout from sampletones_player.song import Song from sampletones_player.specification.compression import ( + PHRASE_DEFAULT_SIZE, PHRASE_LENGTH_SIZE, PHRASE_TABLE_COUNT_SIZE, PHRASE_TABLE_ENTRY_SIZE, @@ -67,7 +68,7 @@ def test_the_first_stream_follows_the_last_body(self) -> None: song = figure_song(LOOP_TICK) layout = SongLayout.of(song) last = song.planes.phrases[len(song.planes.phrases) - 1] - assert layout.streams[0] == layout.bodies[-1] + PHRASE_LENGTH_SIZE + last.length + assert layout.streams[0] == layout.bodies[-1] + PHRASE_LENGTH_SIZE + PHRASE_DEFAULT_SIZE + last.length def test_each_stream_follows_the_one_before_it(self) -> None: song = figure_song(LOOP_TICK) diff --git a/tests/unit/sampletones_player/nsf/test_song.py b/tests/unit/sampletones_player/nsf/test_song.py index bc62d082e..b7eddfa5f 100644 --- a/tests/unit/sampletones_player/nsf/test_song.py +++ b/tests/unit/sampletones_player/nsf/test_song.py @@ -7,9 +7,10 @@ from sampletones_player.compression.decode import decode_planes from sampletones_player.compression.planes.order import PlaneOrder from sampletones_player.nsf.layout import NAME_SEPARATOR, SongLayout -from sampletones_player.nsf.song import song_to_bytes +from sampletones_player.nsf.song import STATED_BEYOND_THE_FIRST, song_to_bytes from sampletones_player.song import Song from sampletones_player.specification.binary import WORD_SIZE +from sampletones_player.specification.compression import PHRASE_DEFAULT_SIZE, PHRASE_LENGTH_SIZE from sampletones_player.specification.planes import PLANE_COUNT from sampletones_player.specification.song import ( ABSENT_STREAM, @@ -193,8 +194,10 @@ def test_each_entry_reaches_that_phrases_length_and_body(self) -> None: data = song_to_bytes(song, PROGRAM_AREA_BYTES) layout = SongLayout.of(song) for phrase, offset in zip(song.planes.phrases.phrases, layout.bodies): + body = offset + PHRASE_LENGTH_SIZE + PHRASE_DEFAULT_SIZE assert data[offset] == phrase.length - assert data[offset + 1 : offset + 1 + phrase.length] == phrase.body + assert data[offset + PHRASE_LENGTH_SIZE] == phrase.default - STATED_BEYOND_THE_FIRST + assert data[body : body + phrase.length] == phrase.body def test_the_entries_stand_where_the_table_states(self) -> None: song = spelled_phrase_song() diff --git a/tests/unit/sampletones_tools/codec/study/test_accounting.py b/tests/unit/sampletones_tools/codec/study/test_accounting.py index f8ceadc51..3a11c4e33 100644 --- a/tests/unit/sampletones_tools/codec/study/test_accounting.py +++ b/tests/unit/sampletones_tools/codec/study/test_accounting.py @@ -4,7 +4,7 @@ import pytest from sampletones_player.compression.dictionary.phrase import Phrase -from sampletones_player.compression.dictionary.table import phrase_table +from sampletones_player.compression.dictionary.table import PhraseTable, phrase_table from sampletones_player.compression.encode import emit from sampletones_player.compression.planes.order import PlaneOrder from sampletones_player.compression.tokens.hold import HoldToken @@ -14,7 +14,7 @@ from sampletones_player.specification.compression import MAX_HOLD_TICKS, TokenTag from sampletones_player.specification.planes import PLANES, PlaneRole from sampletones_tools.codec.study.accounting.coincident import coincident_starts -from sampletones_tools.codec.study.accounting.dictionary import default_counts, plateaus +from sampletones_tools.codec.study.accounting.dictionary import plateaus from sampletones_tools.codec.study.accounting.finding import Finding from sampletones_tools.codec.study.accounting.pairs import ramps_in_literals, set_holds from sampletones_tools.codec.study.accounting.runs import ramps, runs @@ -33,12 +33,12 @@ class TestReadTokensReadsBackWhatEmitWrote: tokens: Final[Tuple[TokenUnion, ...]] = ( HoldToken(ticks=3), LiteralToken(values=b"\x01\x02"), - PhraseToken(phrase_id=2, ticks=4, transpose=0), - PhraseToken(phrase_id=ESCAPED_PHRASE_ID, ticks=2, transpose=3), + PhraseToken(phrase_id=2, ticks=4, transpose=0, default=False), + PhraseToken(phrase_id=ESCAPED_PHRASE_ID, ticks=2, transpose=3, default=False), ) def test_the_tokens_come_back_in_order(self) -> None: - read = read_tokens(emit(self.tokens)) + read = read_tokens(emit(self.tokens), DICTIONARY) assert [token.tag for token in read] == [ TokenTag.HOLD, @@ -51,7 +51,7 @@ def test_the_tokens_come_back_in_order(self) -> None: assert [token.size for token in read] == [token.size for token in self.tokens] def test_the_operands_come_back(self) -> None: - read = read_tokens(emit(self.tokens)) + read = read_tokens(emit(self.tokens), DICTIONARY) assert [token.payload for token in read] == [b"", b"\x01\x02", b"", b""] assert [token.phrase_id for token in read] == [None, None, 2, ESCAPED_PHRASE_ID] @@ -96,6 +96,9 @@ def test_the_constant_step_runs_are_found(self, test_case: TestCase) -> None: assert ramps(test_case.data) == test_case.expected +DICTIONARY: Final[PhraseTable] = phrase_table(tuple(Phrase(body=bytes((value,))) for value in range(4))) + + class TestHoldChains(BaseTestSuite): """A wide hold pays two bytes for up to sixty-four full holds, and one for the ticks left.""" @@ -128,7 +131,7 @@ class TestCase(BaseRegularTestCase): def test_the_chain_is_priced_against_wide_holds(self, test_case: TestCase) -> None: stream = emit([HoldToken(ticks=ticks) for ticks in test_case.holds]) - assert hold_chains(read_tokens(stream)) == test_case.expected + assert hold_chains(read_tokens(stream, DICTIONARY)) == test_case.expected def test_a_literal_breaks_the_chain(self) -> None: stream = emit( @@ -140,7 +143,7 @@ def test_a_literal_breaks_the_chain(self) -> None: ] ) - assert hold_chains(read_tokens(stream)) == Finding(2, 0) + assert hold_chains(read_tokens(stream, DICTIONARY)) == Finding(2, 0) class TestSetHolds(BaseTestSuite): @@ -179,7 +182,7 @@ class TestCase(BaseRegularTestCase): @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) def test_the_pairs_are_priced_at_two_bytes(self, test_case: TestCase) -> None: - assert set_holds(read_tokens(emit(list(test_case.tokens)))) == test_case.expected + assert set_holds(read_tokens(emit(list(test_case.tokens)), DICTIONARY)) == test_case.expected class TestRampsInLiterals(BaseTestSuite): @@ -197,10 +200,13 @@ class TestCase(BaseRegularTestCase): @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) def test_the_ramps_are_priced_at_three_bytes(self, test_case: TestCase) -> None: - assert ramps_in_literals(read_tokens(emit([LiteralToken(values=test_case.values)]))) == test_case.expected + assert ( + ramps_in_literals(read_tokens(emit([LiteralToken(values=test_case.values)]), DICTIONARY)) + == test_case.expected + ) def test_a_ramp_inside_a_hold_is_none(self) -> None: - assert ramps_in_literals(read_tokens(emit([HoldToken(ticks=5)]))) == Finding(0, 0) + assert ramps_in_literals(read_tokens(emit([HoldToken(ticks=5)]), DICTIONARY)) == Finding(0, 0) def _starting_at(ticks: Sequence[int]) -> Tuple[ReadToken, ...]: @@ -237,21 +243,3 @@ def test_the_repeats_beyond_a_runs_first_byte_are_targeted(self) -> None: table = phrase_table((Phrase(body=b"\x01\x01\x01\x02\x02"), Phrase(body=b"\x03\x04"))) assert plateaus(table) == Finding(3, 1) - - -class TestDefaultCounts: - def test_the_modal_count_of_each_phrase_spares_its_bytes_but_one(self) -> None: - table = phrase_table((Phrase(body=b"\x01\x02"), Phrase(body=b"\x03\x04"))) - tokens = read_tokens( - emit( - [ - PhraseToken(phrase_id=0, ticks=4, transpose=0), - PhraseToken(phrase_id=0, ticks=4, transpose=0), - PhraseToken(phrase_id=0, ticks=4, transpose=2), - PhraseToken(phrase_id=0, ticks=6, transpose=0), - PhraseToken(phrase_id=1, ticks=2, transpose=0), - ] - ) - ) - - assert default_counts(table, tokens) == Finding(5, 2) diff --git a/tests/unit/sampletones_tools/codec/study/test_sandbox.py b/tests/unit/sampletones_tools/codec/study/test_sandbox.py index a2731324e..a04eea5af 100644 --- a/tests/unit/sampletones_tools/codec/study/test_sandbox.py +++ b/tests/unit/sampletones_tools/codec/study/test_sandbox.py @@ -36,7 +36,7 @@ from sampletones_tools.codec.study.sandbox.tokens import Hold, Literal, Play, SetHold, StudyToken, WideHold from sampletones_tools.codec.study.sandbox.verify import verify_baseline from sampletones_tools.codec.study.variants.production import compress_baseline -from sampletones_tools.codec.study.variants.sandbox import DEFAULT_COUNT_COSTS, GRAMMAR_VARIANTS, GrammarVariant +from sampletones_tools.codec.study.variants.sandbox import GRAMMAR_VARIANTS, GrammarVariant from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase from tests.suite.player import playable @@ -55,7 +55,6 @@ SET_HOLD_REALLOCATED: Final[Grammar] = replace( BASELINE_GRAMMAR, set_hold=True, costs=replace(PRODUCTION_COSTS, set_hold=SET_HOLD_BOUND) ) -DEFAULT_COUNTS: Final[Grammar] = replace(BASELINE_GRAMMAR, default_counts=True, costs=DEFAULT_COUNT_COSTS) def _figures_plane(random: Random, ticks: int) -> bytes: @@ -301,7 +300,7 @@ class TestCase(BaseRegularTestCase): label="a phrase played at its default count carries no count", plane=repeated, table=repeated_table, - grammar=DEFAULT_COUNTS, + grammar=BASELINE_GRAMMAR, defaults=(4,), expected=3, ), @@ -309,7 +308,7 @@ class TestCase(BaseRegularTestCase): label="a phrase played at another count carries it", plane=repeated, table=repeated_table, - grammar=DEFAULT_COUNTS, + grammar=BASELINE_GRAMMAR, defaults=(3,), expected=6, ), @@ -329,11 +328,6 @@ def test_a_phrase_beyond_the_cheap_ids_pays_the_escape(self) -> None: assert PRODUCTION_COSTS.phrase(CHEAP_PHRASE_IDS, 0, default=False) == 3 assert PRODUCTION_COSTS.phrase(0, 3, default=False) == 3 - def test_default_counts_halve_the_cheap_ids_and_widen_the_entries(self) -> None: - assert DEFAULT_COUNT_COSTS.phrase(30, 0, default=True) == 1 - assert DEFAULT_COUNT_COSTS.phrase(31, 0, default=True) == 2 - assert DEFAULT_COUNT_COSTS.dictionary(TABLE) == TABLE.size + len(TABLE) - def test_offered_lengths_are_the_longest_alone_or_every_one(self) -> None: assert tuple(offered_lengths(5, least=2, every=False)) == (5,) assert tuple(offered_lengths(5, least=2, every=True)) == (2, 3, 4, 5) diff --git a/tests/unit/sampletones_tools/player/test_song_include.py b/tests/unit/sampletones_tools/player/test_song_include.py index aa6d78783..03248a9c1 100644 --- a/tests/unit/sampletones_tools/player/test_song_include.py +++ b/tests/unit/sampletones_tools/player/test_song_include.py @@ -9,8 +9,11 @@ from sampletones_player.specification.binary import WORD_SIZE from sampletones_player.specification.compression import ( BEND_FLAG, + DEFAULT_COUNT_FLAG, OPCODE_SIZE, + PHRASE_DEFAULT_SIZE, PHRASE_ID_ESCAPE, + PHRASE_ID_MASK, PHRASE_LENGTH_SIZE, PHRASE_TABLE_COUNT_SIZE, PHRASE_TABLE_ENTRY_SIZE, @@ -79,6 +82,9 @@ "PHRASE_TABLE_COUNT_SIZE": PHRASE_TABLE_COUNT_SIZE, "PHRASE_TABLE_ENTRY_SIZE": PHRASE_TABLE_ENTRY_SIZE, "PHRASE_LENGTH_SIZE": PHRASE_LENGTH_SIZE, + "PHRASE_DEFAULT_SIZE": PHRASE_DEFAULT_SIZE, + "DEFAULT_COUNT_FLAG": DEFAULT_COUNT_FLAG, + "PHRASE_ID_MASK": PHRASE_ID_MASK, "PLANE_STATE_SIZE": PLANE_STATE_SIZE, "PLANE_STATE_BYTES": PLANE_COUNT * PLANE_STATE_SIZE, "COUNT_STEP": COUNT_STEP, From 5d9bae5c122f651e2a8a5bbd9992e10dea6ab28d Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 21 Sep 2026 14:46:14 +0200 Subject: [PATCH 07/11] Wrote: what a plane's byte holds and what the driver reads from it --- docs/concepts/compression.md | 53 +++++++++++-------- docs/development/bugs-and-todos.md | 4 -- docs/development/player.md | 31 ++++++++--- docs/formats/nsf.md | 82 +++++++++++++++++++++--------- 4 files changed, 112 insertions(+), 58 deletions(-) diff --git a/docs/concepts/compression.md b/docs/concepts/compression.md index 528e27efe..0fdc40f41 100644 --- a/docs/concepts/compression.md +++ b/docs/concepts/compression.md @@ -49,11 +49,12 @@ The first thing the encoder does is unbraid them. Each register becomes a **plan one byte per tick, the whole song long — and each plane is a series of its own: a volume envelope that falls and holds, a pitch line that steps between notes, a duty cycle that barely moves. An idle channel's planes become one value repeated, which -costs almost nothing to state, and a plane holding zero throughout — a bend a channel never -makes — is left out of the block altogether. +costs almost nothing to state, and a plane standing at the value it is seeded to +throughout — a bend a channel never makes, a control byte of a channel that never sounds — +is left out of the block altogether. This one change is most of the win. Split into planes and coded, the three-minute -arrangement falls from 11 bytes a tick to about 1.7. +arrangement falls from 11 bytes a tick to about 3.0. ### 2.2 Pitches instead of dividers @@ -63,8 +64,8 @@ dividers, and the steps between them are uneven. The encoder replaces the two divider planes with a **pitch index** — how far the frame's note sits above the lowest pitch the table covers — and a **bend**, the divider steps the tick stands away from that note's own divider. The song block carries a table the driver resolves the index through -and adds the bend to. A tone channel is therefore three planes, the same count as the -registers it writes. Counting a bend from the note keeps it the same bytes wherever a row +and adds the bend to. A pulse channel is therefore three planes; the triangle is two, +since it sounds at one level and names its silence in the pitch its value plane carries. Counting a bend from the note keeps it the same bytes wherever a row transposes the note to; only a bend past the signed byte is counted from the pitch lying nearest the divider instead. @@ -112,6 +113,12 @@ added to each of its values, wrapping at 256. On the 6502 that is a single addit and a fall in pitch is simply the byte that wraps around to it. Together with the duration rule, one entry covers every pitch *and* every length a figure is played at. +**A phrase may state its own count.** A figure played at one length throughout states +that length once, in the phrase's own entry, and every token playing it there names the +phrase alone. The bit that says so comes out of the opcode's id field, so a phrase +opcode names 31 ids outright where a hold counts to 64; the phrases a song leans on +hardest take those ids, and the rest name themselves in a further byte. + ## 4. Reading a plane the cheapest way A plane usually admits many readings. A run of eight identical values can be one hold, @@ -252,26 +259,27 @@ above it: | what is stored | bytes per tick | ratio | ticks that fit | |---|---|---|---| -| a record per tick per channel | 11.000 | 1.00 | 2914 | -| planes, coded | 3.517 | 3.13 | 9115 | -| planes with a pitch index and a bend | 3.604 | 3.05 | 8885 | -| phrases from the instruments | 1.989 | 5.53 | 16201 | -| phrases played transposed | 1.593 | 6.90 | 20294 | -| phrases from the search as well | **1.346** | **8.17** | **24130** | +| a record per tick per channel | 11.000 | 1.00 | 2907 | +| planes, coded | 3.517 | 3.13 | 9092 | +| planes with a pitch index and a bend | 2.980 | 3.69 | 10731 | +| phrases from the instruments | 1.717 | 6.41 | 18750 | +| phrases played transposed | 1.447 | 7.60 | 22326 | +| phrases from the search as well | **0.874** | **12.59** | **37660** | The arrangement bends most of the notes its pulse channel plays, and its bend plane carries every one of them; a song played straight leaves its bend planes out of the -block. The whole song is 14534 bytes of the roughly 32000 available, and **24130 ticks -is 6.7 minutes at 60 Hz**, against the 49 seconds a record per tick reaches. Encoding it +block. The whole song is 9435 bytes of the roughly 32000 available, and **37660 ticks +is 10.5 minutes at 60 Hz**, against the 49 seconds a record per tick reaches. Encoding it costs between two and three seconds; decoding it costs the console around twenty -instructions per plane per tick, comfortably inside a video frame. +instructions per plane per tick, and fewer on a tick a symbol still covers, comfortably +inside a video frame. `uv run sampletones codec report` writes this table over a corpus of songs, and the format's constants are settled from it. Two of them were settled against expectation: splitting the duty cycle out of the control byte into a plane of its own **costs** 9 %, because volume and duty turn over together and a split pays two opcodes for what one covers; -and the pitch index earns its place through the transposition it makes possible, 20 %, -where directly it costs the bent arrangement about 2 %. +and the pitch index earns its place through the transposition it makes possible, 16 %, +where directly it pays for itself once a plane counts its own repeats. An export chooses how far down these layers it goes. The **Level** it is written at names the layers read in order: *None* spells every plane out as literals, *Held sounds* adds holds, @@ -301,16 +309,17 @@ plays the same song. | quantity | value | |---|---| -| planes | control and value for every channel, and a bend for each tone channel | +| planes | a value for every channel, a control for the three that carry one, and a bend for each tone channel | | bytes per tick before coding | 11 | -| ticks one hold covers | 1 to 64 | -| values one literal carries | 1 to 64 | -| ticks one phrase token covers | 1 to 256 | -| phrase ids inside the opcode | 63 | +| symbols one hold covers | 1 to 64 | +| symbols one literal carries | 1 to 64 | +| symbols one phrase token covers | 1 to 256 | +| ticks one symbol covers | 1 to 16, by the plane | +| phrase ids inside the opcode | 31 | | phrases in the dictionary | up to 255 | | values in a phrase | up to 255 | | candidate lengths the search gathers | 3 to 48 | -| decoder state on the console | 88 bytes of zero page, 8 per plane | +| decoder state on the console | 100 bytes of zero page, 10 per plane | Where things live: diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index f3ed35e35..4ad8d023e 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -82,10 +82,6 @@ starts carrying. ### Technical -* Re-measuring what the codec achieves. The ladder in `docs/concepts/compression.md` §6, its - prose figures and the Appendix are the reading taken before a plane counted its own repeats - and before the triangle named its silence; `uv run sampletones codec report` rewrites them - once the format settles. * Leading the calibration report with `mr-loudness-dB`. `build_referees` puts `mr-auditory-dB` first, which reads silence as closer to a tone than any render, until by-ear ratings of a sweep hold the loudness-weighted referee at ρ ≥ 0.6 in every category. A listening round in September diff --git a/docs/development/player.md b/docs/development/player.md index 06987d89a..246a6fa04 100644 --- a/docs/development/player.md +++ b/docs/development/player.md @@ -97,10 +97,23 @@ from that pitch. It saves a byte a tick directly, but the reason it matters is t cannot be transposed and an index can: the same figure played at several pitches is several copies in timer space and one entry plus a shift in index space. +**A plane repeats from its own byte.** Four of the planes write a byte the APU only +partly reads — two bits a pulse control byte wants set, the top nibble of a noise control +byte, three bits above a noise period — and those spare bits carry the ticks the value +repeats for. A rest costs one byte however long it lasts, and the planes that rest longest +are exactly the ones with room to say so. The division follows from the register, so the +block states none of it and the driver knows it by plane. + +**The triangle names its silence in the pitch it plays.** The channel sounds at one level, +so the index its value plane carries is enough to say whether it sounds at all: an index +above every pitch the table holds silences the linear counter. That spares a whole plane, +in the block and on the tick path both. + **Tokens.** A plane is written as holds, literals and phrase plays — the encoding is in [the format document](../formats/nsf.md#b4-the-token-streams). What matters here is that a token's count is a duration rather than a length, so one dictionary entry serves a figure -however long it is held and whatever pitch it is played at. +however long it is held and whatever pitch it is played at, and that a phrase may state +the count its tokens play it at most often so those tokens carry none. **The cheapest reading, not a greedy one.** A plane is parsed as a shortest path: every way of covering a tick is an edge priced in the bytes its token takes, and the cheapest path @@ -139,7 +152,8 @@ seeded into page zero, which no song occupies, and the advance passes it by, so the zero every plane starts from. A bend plane is stepped only on a tick its channel's new value flags, since it holds a value for those ticks alone. A plane's state carries where its next token lies, where in a phrase body it stands, how much of that body is left, how much of the -current token is left, the value it last played, and the shift it is playing at. The three +current token is left, the symbol it last played, the ticks that symbol still covers, the +bits its own byte counts in, and the shift it is playing at. The three kinds of token fold into that one shape — a hold is a phrase of no bytes, a literal is a phrase whose bytes lie inline behind its opcode — so playing a tick is the same handful of instructions whichever token is standing. @@ -149,11 +163,14 @@ is a base offset held in `X`, the way a channel's register base is, so every pla is one routine called again. The state lives in zero page, well inside what the driver leaves free, and the linker configuration keeps the two-segment memory model an NSF loads. -**The one sum the driver performs is the bend.** A tone channel's value plane resolves to a -divider through the timer table, and on a flagged tick its bend plane states the steps the -tick stands away from it — sign-extended and added across both halves of the timer, with the -high half reaching the register only where it changed. An unflagged tick adds nothing. Everything that keeps the sum in range is -settled in Python, so what crosses into assembly stays a byte moved and a carry followed. +**The driver's arithmetic is the bend and the repeat.** A tone channel's value plane +resolves to a divider through the timer table, and on a flagged tick its bend plane states +the steps the tick stands away from it — sign-extended and added across both halves of the +timer, with the high half reaching the register only where it changed. An unflagged tick +adds nothing. The other is a subtraction: a tick a symbol still covers steps the plane's +count down and returns, which makes the common tick cheaper than reaching a new symbol. +Everything that keeps either in range is settled in Python, so what crosses into assembly +stays a byte moved and a carry followed. ## How it is verified diff --git a/docs/formats/nsf.md b/docs/formats/nsf.md index 61fb9a5a0..73bc7990d 100644 --- a/docs/formats/nsf.md +++ b/docs/formats/nsf.md @@ -57,9 +57,9 @@ plays from wherever the file loads it. ``` +0 header -+55 timer table ++51 timer table phrase table: count, then one offset per phrase - phrase bodies: each a length byte, then its values + phrase bodies: each a length byte, a count byte, then its values one token stream per plane, in plane order ``` @@ -74,7 +74,7 @@ plays from wherever the file loads it. | +7 | 2 | where the timer table begins | | +9 | 2 | where the phrase table begins | | +11 | `PLANE_COUNT`×2 | where each plane's stream begins, or `$FFFF` for an absent plane | -| +33 | `PLANE_COUNT`×2 | where each plane's stream is re-entered once the song comes round, or `$FFFF` for an absent plane | +| +31 | `PLANE_COUNT`×2 | where each plane's stream is re-entered once the song comes round, or `$FFFF` for an absent plane | All fields are little-endian, and the header runs to `SONG_HEADER_SIZE` bytes. @@ -105,13 +105,17 @@ function the reconstruction's own generators render from. ``` count 1 byte, how many phrases the table holds offsets one uint16 per phrase, in id order -bodies each phrase: a length byte, then its values +bodies each phrase: a length byte, a count byte, then its values ``` A **phrase** is a run of values a plane plays, stored at the pitch it was found at. Its position in the table is its **id**, and the ids that ride inside a token's opcode are the cheap ones, so the phrases a song leans on hardest are listed first. +**A phrase states the count its tokens play it at most often.** The count byte holds that +count less one, the way a token states one, and a token playing it names the phrase alone +and spends a byte fewer. + A song's phrases come from two places: the samples it plays, each offering the planes it writes, and a search over whatever those leave uncovered. Both are weighed the same way — a phrase keeps its entry by sparing the streams more bytes than the entry costs. @@ -122,26 +126,35 @@ Each plane is a byte sequence written as tokens. The opcode's top two bits name and the low six carry its operand: ``` -00cccccc hold the value the plane reached, for c+1 ticks -01nnnnnn b0..bn the n+1 bytes that follow, one per tick -10pppppp cccccccc phrase p, for c+1 ticks -11pppppp cccccccc tt phrase p, for c+1 ticks, every value plus tt +00cccccc hold the value the plane reached, for c+1 symbols +01nnnnnn b0..bn the n+1 symbols that follow, one each +10dppppp cccccccc phrase p, for c+1 symbols +11dppppp cccccccc tt phrase p, for c+1 symbols, every value plus tt ``` -`p == $3F` escapes: the phrase's id is the byte that follows, which reaches every id in +`p == $1F` escapes: the phrase's id is the byte that follows, which reaches every id in the table while the low ones stay a byte cheaper. The shift `tt` is **added within the byte**, wrapping — one addition on the 6502, and the same one the encoder agrees with. +**The `d` bit says the count is the phrase's own.** A phrase token carrying it names the +phrase and no count, and plays the count the table states for that phrase; the count byte +`cccccccc` is then absent. That bit is what leaves five bits for an id, so a phrase opcode +names ids up to `$1F` outright where a hold or a literal counts to `$3F`. + **A token's count is a duration, and it may run past the phrase.** Past its last value the plane holds that value onward, which is how a note whose envelope has finished keeps sounding; a count short of the body cuts the note off. One entry therefore serves every length a figure is played at, and — with the shift — every pitch. +**A token counts symbols.** On a plane whose byte carries a repeat count (§C) one symbol +covers as many ticks as it states, so a token's reach in ticks is the ticks its symbols +carry between them. On every other plane a symbol is a tick. + ### B.5 Where a song comes round A song that repeats re-enters its streams partway through, so the tick it returns to -begins a token on every plane, and that token names its values outright rather than -leaning on the value the plane had reached. A bend plane is re-entered at the value its +begins a symbol, and a token, on every plane, and that token names its values outright +rather than leaning on the value the plane had reached. A bend plane is re-entered at the value its channel's flags have reached by that tick (§C), which begins a token of its own. Coming round is then a matter of pointing each plane at the byte the header states and clearing what it was playing. @@ -156,17 +169,16 @@ The planes are written in this order, and each group belongs to one channel: | Plane | Carries | Reaches | |---|---|---| -| pulse 1 control | duty cycle and volume | `$4000` | +| pulse 1 control | duty cycle and volume, under a repeat count | `$4000` | | pulse 1 value | pitch index, and a flag for a bent tick | `$4002`, `$4003` | | pulse 1 bend | divider offset of each flagged tick | `$4002`, `$4003` | -| pulse 2 control | duty cycle and volume | `$4004` | +| pulse 2 control | duty cycle and volume, under a repeat count | `$4004` | | pulse 2 value | pitch index, and a flag for a bent tick | `$4006`, `$4007` | | pulse 2 bend | divider offset of each flagged tick | `$4006`, `$4007` | -| triangle control | linear counter | `$4008` | -| triangle value | pitch index, and a flag for a bent tick | `$400A`, `$400B` | +| triangle value | pitch index, a flag for a bent tick, and the index that rests | `$4008`, `$400A`, `$400B` | | triangle bend | divider offset of each flagged tick | `$400A`, `$400B` | -| noise control | volume | `$400C` | -| noise value | period and mode | `$400E` | +| noise control | volume, under a repeat count | `$400C` | +| noise value | period and mode, around a repeat count | `$400E` | Splitting a channel's registers apart is what gives each plane something to repeat: a volume envelope and a pitch line are separate series that turn over at their own rates. @@ -175,10 +187,27 @@ volume envelope and a pitch line are separate series that turn over at their own silent values from the first tick to the last, which a hold covers in a few bytes, so the driver reads the same layout whichever channels a song sounds. -**A plane playing zero throughout is absent.** Every plane starts at zero, so a plane that -never leaves it takes no stream: both its header entries state `ABSENT_STREAM` (`$FFFF`), -and the driver leaves it standing at zero on every tick. A tone channel that never bends -costs its bend plane nothing this way. +**A plane counts its repeats in the bits its register ignores.** A pulse control byte +holds a duty cycle and a volume around two bits the hardware wants set; a noise control +byte holds a volume under the same two; a noise period byte holds a mode bit above three +the register reads nothing from. Those spare bits carry the ticks the value repeats for, +so a run costs one byte however long it lasts. The register byte is the symbol masked and +ored with the bits the hardware fixes, and one repeat is a subtraction of `COUNT_STEP`; +every other plane spends its whole byte and reads a symbol a tick. The division follows +from the plane, so the block states none of it. + +**A plane standing at the value it is seeded to throughout is absent.** The driver seeds +every plane before its first token — to the bits its register fixes where it has any, and +to the index that rests on the triangle's value plane — and a plane that never leaves that +value takes no stream: both its header entries state `ABSENT_STREAM` (`$FFFF`). A tone +channel that never bends costs its bend plane nothing this way, a pulse or noise channel +that never sounds costs its control plane nothing, and a song without a triangle costs its +value plane nothing. + +**The triangle names its silence in the pitch it plays.** The channel sounds at one level, +so a tick states whether it sounds through the index its value plane names: the index +standing above every pitch the table covers silences the linear counter, and the divider +stays where the channel last sounded. That is a whole plane the block does without. **The noise channel reads no bend.** It selects one of sixteen fixed periods, so there is no finer grid for a bend to reach, and the plane it would hold is left out of the block. @@ -211,8 +240,9 @@ divider halfway between two pitches going to the higher one; those steps stay in the widest gap between neighboring pitches — 57 at the default tuning — so every divider the register holds reaches the planes. -The driver sign-extends that byte and adds it across both halves of the timer, which is the -only arithmetic it performs on a song's behalf. Everything that makes the sum land in +The driver sign-extends that byte and adds it across both halves of the timer, which with +the repeat a symbol counts down is the whole of the arithmetic it performs on a song's +behalf. Everything that makes the sum land in range is settled in Python: `bent_timer` keeps the divider within `[MIN_TIMER, MAX_TIMER]`, the rule the generators render a bent frame by, so the timer's high half never exceeds three bits, never reaches the length-counter field beside them, and never collides with the @@ -225,10 +255,12 @@ three bits, never reaches the length-counter field beside them, and never collid | Program area | 32 KB from `$8000`, less the driver | | Song length | as many ticks as the streams fit in | | Offsets within the block | `uint16` | -| Ticks one hold or literal covers | up to `MAX_HOLD_TICKS` / `MAX_LITERAL_BYTES` | -| Ticks one phrase token covers | up to `MAX_PHRASE_TICKS` | +| Symbols one hold or literal covers | up to `MAX_HOLD_TICKS` / `MAX_LITERAL_BYTES` | +| Symbols one phrase token covers | up to `MAX_PHRASE_TICKS` | +| Ticks one symbol covers | up to the plane's own count, 16 at the widest | | Values one phrase holds | up to `MAX_PHRASE_LENGTH` | | Phrases one dictionary holds | up to `MAX_PHRASE_IDS` | +| Phrase ids a token's opcode names | up to `CHEAP_PHRASE_IDS` | A song reaching past the space behind the driver, or past what an offset field states, raises `SongTooLargeError` naming the part that overflowed. A project offering more From 5db72c7d3bfa5a5749c9975eb7ba36a456e67d6a Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 21 Sep 2026 17:43:11 +0200 Subject: [PATCH 08/11] Read: a plane's absence against the value it is seeded to --- .../codec/report/encoding.py | 39 +++++++++++++------ .../codec/study/sandbox/context.py | 19 ++++----- .../codec/study/sandbox/reference.py | 18 +++++++-- .../codec/study/test_sandbox.py | 4 +- 4 files changed, 54 insertions(+), 26 deletions(-) diff --git a/src/sampletones_tools/codec/report/encoding.py b/src/sampletones_tools/codec/report/encoding.py index 4509d65f8..268f23394 100644 --- a/src/sampletones_tools/codec/report/encoding.py +++ b/src/sampletones_tools/codec/report/encoding.py @@ -14,9 +14,8 @@ from sampletones_player.compression.planes.song import SongPlanes from sampletones_player.compression.scheme import CompressionScheme from sampletones_player.registers.streams import ChannelStreams -from sampletones_player.specification.planes import PLANES, PlaneRole +from sampletones_player.specification.planes import NO_BITS, PLANES, PlaneRole from sampletones_player.specification.registers import DUTY_CYCLE_SHIFT -from sampletones_tools.codec.absent import is_absent from sampletones_tools.codec.report.corpus import CorpusEntry from sampletones_tools.codec.report.rows import ReportRow @@ -204,17 +203,34 @@ def _measured_row( def _register_planes(streams: ChannelStreams) -> Tuple[bytes, ...]: - return tuple( - bytes(tick.values[register] for tick in stream) - for stream in streams.padded - for register in range(len(stream[0].values)) - ) + """Every register as a plane, the ones a channel leaves at the value it resets to left out.""" + written: List[bytes] = [] + for stream in streams.padded: + for register in range(len(stream[0].values)): + plane = bytes(tick.values[register] for tick in stream) + if set(plane) != {NO_BITS}: + written.append(plane) + + return tuple(written) def _split_control_planes(planes: SongPlanes) -> Tuple[bytes, ...]: - """Every plane with each pulse channel's control split into duty and volume.""" + """Every plane the block writes, with each pulse channel's control split into duty and volume. + + A plane standing at the value it is seeded to throughout takes no stream, so it takes no + bytes here either, and a control plane splits into the two series a split layout would store. + + Args: + planes: The song's planes. + + Returns: + Tuple[bytes, ...]: The series the layout stores, in the order the block writes them. + """ split: List[bytes] = [] for plane, played in zip(PLANES, planes.planes, strict=True): + if plane.idles(played): + continue + if plane.role is PlaneRole.CONTROL and plane.channel in PULSE_CHANNELS: split.append(bytes(control >> DUTY_CYCLE_SHIFT for control in played)) split.append(bytes(control & CONTROL_LEVEL_MASK for control in played)) @@ -225,9 +241,8 @@ def _split_control_planes(planes: SongPlanes) -> Tuple[bytes, ...]: def _coded_size(planes: Sequence[bytes], options: CodecOptions) -> int: - """The bytes the planes take under holds and literals, an absent plane taking none.""" - present = [plane for plane in planes if not is_absent(plane)] - cache = MatchCache(PlaneIndex.from_plane(plane) for plane in present) + """The bytes the planes take under holds and literals.""" + cache = MatchCache(PlaneIndex.from_plane(plane) for plane in planes) table = phrase_table(()) entries = frozenset({STREAM_START}) return sum( @@ -236,5 +251,5 @@ def _coded_size(planes: Sequence[bytes], options: CodecOptions) -> int: options, entries, ).size - for plane in range(len(present)) + for plane in range(len(planes)) ) diff --git a/src/sampletones_tools/codec/study/sandbox/context.py b/src/sampletones_tools/codec/study/sandbox/context.py index 60051a41f..78e942084 100644 --- a/src/sampletones_tools/codec/study/sandbox/context.py +++ b/src/sampletones_tools/codec/study/sandbox/context.py @@ -4,7 +4,6 @@ from sampletones_player.compression.matches.index import PlaneIndex from sampletones_player.compression.matches.matcher import PhraseMatcher from sampletones_player.compression.parse.boundaries import Boundaries -from sampletones_player.specification.compression import INITIAL_PLANE_VALUE from sampletones_tools.codec.study.sandbox.costs import Costs @@ -14,10 +13,11 @@ class PlaneContext: Attributes: index: The plane, its steps and its runs. - matcher: Which phrases the plane plays at a tick, and for how long. - boundaries: The ticks a token starts on, read either way from every tick. + matcher: Which phrases the plane plays at a symbol, and for how long. + boundaries: The symbols a token starts on, read either way from every symbol. transposition: Whether a phrase may play at a shift. defaults: The default count of each phrase, by id, zero where the phrase has none. + seeded: The symbol the plane stands at before its first token. costs: The bytes each token takes. """ @@ -26,18 +26,19 @@ class PlaneContext: boundaries: Boundaries transposition: bool defaults: Tuple[int, ...] + seeded: int costs: Costs def value_before(self, position: int) -> int: - """The value the plane holds as ``position`` is reached, which a hold there keeps. + """The symbol the plane holds as ``position`` is reached, which a hold there keeps. - The driver seeds every plane before the first token, so the stream's first tick is - reached holding that value like any other. + The driver seeds every plane before the first token, so the stream's first symbol is + reached holding the seeded symbol like any other. Args: - position: The tick. + position: The symbol. Returns: - int: The value. + int: The symbol held. """ - return self.index.plane[position - 1] if position > 0 else INITIAL_PLANE_VALUE + return self.index.plane[position - 1] if position > 0 else self.seeded diff --git a/src/sampletones_tools/codec/study/sandbox/reference.py b/src/sampletones_tools/codec/study/sandbox/reference.py index 4f080fea5..850d5f1f7 100644 --- a/src/sampletones_tools/codec/study/sandbox/reference.py +++ b/src/sampletones_tools/codec/study/sandbox/reference.py @@ -10,7 +10,7 @@ from sampletones_player.compression.options import EVERY_LAYER from sampletones_player.compression.parse.boundaries import Boundaries from sampletones_player.compression.planes.symbols import pack_plane -from sampletones_player.specification.planes import PLANES +from sampletones_player.specification.planes import PLANES, SINGLE_TICK from sampletones_tools.codec.study.corpus.song import StudySong from sampletones_tools.codec.study.sandbox.context import PlaneContext from sampletones_tools.codec.study.sandbox.costs import Costs @@ -39,6 +39,7 @@ class Reference: planes: The byte series each stream was written from, in song-block order. table: The dictionary the production codec settled on. cache: What each phrase plays against each written plane, shared by every grammar's parse. + seeds: The symbol each written plane stands at before its first token. baseline: Every plane under the baseline grammar, held to the production streams. """ @@ -46,6 +47,7 @@ class Reference: planes: Tuple[bytes, ...] table: PhraseTable cache: MatchCache + seeds: Tuple[int, ...] baseline: Tuple[StudyParse, ...] def parses( @@ -62,7 +64,7 @@ def parses( Returns: Tuple[StudyParse, ...]: One parse per plane, in song-block order, an absent plane's empty. """ - return plane_parses(self.planes, self.cache, self.table, grammar, defaults) + return plane_parses(self.planes, self.cache, self.table, grammar, defaults, self.seeds) def _contexts( @@ -70,6 +72,7 @@ def _contexts( table: PhraseTable, costs: Costs, defaults: Sequence[int], + seeds: Sequence[int], ) -> Tuple[PlaneContext, ...]: contexts = [] for plane, index in enumerate(cache.indices): @@ -80,6 +83,7 @@ def _contexts( boundaries=Boundaries.across(index.ticks, STREAM_ENTRIES), transposition=EVERY_LAYER.transposition, defaults=tuple(defaults), + seeded=seeds[plane], costs=costs, ) ) @@ -93,8 +97,10 @@ def plane_parses( table: PhraseTable, grammar: Grammar, defaults: Sequence[int], + seeds: Sequence[int], ) -> Tuple[StudyParse, ...]: - written = iter(parse_plane(context, grammar) for context in _contexts(cache, table, grammar.costs, defaults)) + contexts = _contexts(cache, table, grammar.costs, defaults, seeds) + written = iter(parse_plane(context, grammar) for context in contexts) return tuple(next(written) if plane else ABSENT_PARSE for plane in planes) @@ -119,14 +125,18 @@ def reference( for plane, played in zip(PLANES, song.planes.planes, strict=True) ) cache = MatchCache(PlaneIndex.from_plane(plane) for plane in planes if plane) + seeds = tuple( + plane.form.symbol(plane.seeded, SINGLE_TICK) for plane, written in zip(PLANES, planes, strict=True) if written + ) table = compressed.phrases carried = tuple(phrase.default for phrase in table.phrases) - baseline = plane_parses(planes, cache, table, BASELINE_GRAMMAR, carried) + baseline = plane_parses(planes, cache, table, BASELINE_GRAMMAR, carried, seeds) verify_baseline(baseline, compressed) return Reference( song=song, planes=planes, table=table, cache=cache, + seeds=seeds, baseline=baseline, ) diff --git a/tests/unit/sampletones_tools/codec/study/test_sandbox.py b/tests/unit/sampletones_tools/codec/study/test_sandbox.py index a04eea5af..cd3198cac 100644 --- a/tests/unit/sampletones_tools/codec/study/test_sandbox.py +++ b/tests/unit/sampletones_tools/codec/study/test_sandbox.py @@ -21,7 +21,7 @@ CHEAP_PHRASE_IDS, MAX_HOLD_TICKS, ) -from sampletones_player.specification.planes import PLANE_COUNT, PLANES, PlaneRole +from sampletones_player.specification.planes import NO_BITS, PLANE_COUNT, PLANES, PlaneRole from sampletones_shared.music import Tuning from sampletones_tools.codec.study.corpus.song import SongGroup, StudySong from sampletones_tools.codec.study.sandbox.context import PlaneContext @@ -42,6 +42,7 @@ from tests.suite.player import playable from tests.suite.study import NO_STUDY_SLICES, lowest_notes +SEEDED_SYMBOL: Final[int] = NO_BITS ENTRIES: Final[FrozenSet[int]] = frozenset({STREAM_START}) FIGURES: Final[Tuple[bytes, ...]] = (b"\x0a\x0c\x0f\x0f\x0f\x0f\x0f\x0f", b"\x03\x04\x05\x06") TABLE: Final[PhraseTable] = phrase_table(Phrase(body=body) for body in FIGURES) @@ -116,6 +117,7 @@ def _context( boundaries=Boundaries.across(len(plane), ENTRIES), transposition=EVERY_LAYER.transposition, defaults=tuple(defaults), + seeded=SEEDED_SYMBOL, costs=costs, ) From 491513a6011499bc8b7357ce07abdb78c57d4321 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 21 Sep 2026 17:44:56 +0200 Subject: [PATCH 09/11] Stated: the conditions the duty-cycle split was measured under --- docs/concepts/compression.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/concepts/compression.md b/docs/concepts/compression.md index 1b56e2e9d..2a109c1e9 100644 --- a/docs/concepts/compression.md +++ b/docs/concepts/compression.md @@ -290,9 +290,10 @@ frame. The report writes this table over a corpus of songs, and the format's constants are settled from it. Two of them were settled against expectation. Splitting the duty cycle out of the control byte into a plane of -its own **costs** bytes: 16 % on the arrangement above with no plane packed at all, because volume and -duty turn over together and a split pays two opcodes for what one covers, and 56 % as the format stands, -because a split plane also gives up the repeat count its register's spare bits carry. The pitch index +its own **costs** bytes. On the arrangement above, encoded at every layer, it costs 16 % where no plane +packs, because volume and duty turn over together and a split pays two opcodes for what one covers, and +56 % where the planes pack as the format packs them, because a split plane also gives up the repeat count +its register's spare bits carry. The pitch index earns its place through the transposition it makes possible, the fourth row against the fifth, and it pays for itself directly as well (§2.2). From 1e4055b7716f9b45683846b7e6be68bd2ccd8637 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 21 Sep 2026 17:58:03 +0200 Subject: [PATCH 10/11] Reformat --- src/sampletones_player/builder.py | 4 +- src/sampletones_player/clock/schedule.py | 5 +- src/sampletones_player/compression/admit.py | 20 ++- .../compression/compressed.py | 4 +- src/sampletones_player/compression/encode.py | 26 +++- .../compression/matches/cache.py | 6 +- .../compression/matches/matcher.py | 6 +- .../compression/parse/plane.py | 21 +++- .../compression/planes/flags.py | 5 +- .../compression/planes/separate.py | 6 +- .../compression/progress/monitor.py | 5 +- src/sampletones_player/compression/scheme.py | 7 +- src/sampletones_player/compression/search.py | 27 +++- src/sampletones_player/driver/image.py | 10 +- src/sampletones_player/export/reports.py | 5 +- src/sampletones_player/export/writer.py | 6 +- src/sampletones_player/nsf/layout.py | 5 +- src/sampletones_player/registers/pulse.py | 5 +- src/sampletones_player/registers/triangle.py | 11 +- .../specification/channels.py | 6 +- .../specification/planes.py | 6 +- .../sampletones_player/clock/test_schedule.py | 40 ++++-- .../sampletones_player/clock/test_step.py | 10 +- .../compression/dictionary/test_phrase.py | 8 +- .../compression/dictionary/test_table.py | 10 +- .../compression/matches/test_matcher.py | 13 +- .../compression/parse/test_boundaries.py | 4 +- .../compression/parse/test_plane.py | 30 +++-- .../compression/planes/test_flags.py | 4 +- .../compression/planes/test_order.py | 8 +- .../compression/planes/test_rebuild.py | 12 +- .../compression/planes/test_separate.py | 15 ++- .../compression/planes/test_song.py | 31 ++++- .../compression/test_admit.py | 6 +- .../compression/test_budget.py | 41 +++++- .../compression/test_decode.py | 40 ++++-- .../compression/test_encode.py | 13 +- .../compression/test_entries.py | 11 +- .../compression/test_scheme.py | 16 ++- .../compression/test_search.py | 21 +++- .../compression/test_seeds.py | 6 +- .../compression/test_song.py | 4 +- .../compression/tokens/test_hold.py | 5 +- .../compression/tokens/test_literal.py | 4 +- .../compression/tokens/test_phrase.py | 7 +- .../compression/tokens/test_span.py | 39 +++++- .../driver/test_addresses.py | 6 +- .../sampletones_player/driver/test_image.py | 9 +- .../sampletones_player/export/test_backend.py | 106 +++++++++++++--- .../sampletones_player/export/test_program.py | 15 ++- .../sampletones_player/nsf/test_header.py | 13 +- .../nsf/test_information.py | 30 ++++- .../sampletones_player/nsf/test_layout.py | 25 +++- .../unit/sampletones_player/nsf/test_song.py | 27 +++- .../registers/test_channel.py | 31 ++++- .../registers/test_dividers.py | 34 +++-- .../registers/test_pulse.py | 50 ++++++-- .../registers/test_streams.py | 17 ++- .../registers/test_triangle.py | 46 +++++-- .../specification/test_planes.py | 18 ++- tests/unit/sampletones_player/test_builder.py | 117 ++++++++++++++---- .../test_instrument_song.py | 29 ++++- tests/unit/sampletones_player/test_song.py | 51 ++++++-- 63 files changed, 989 insertions(+), 229 deletions(-) diff --git a/src/sampletones_player/builder.py b/src/sampletones_player/builder.py index 39d9c2581..9c2bff749 100644 --- a/src/sampletones_player/builder.py +++ b/src/sampletones_player/builder.py @@ -129,7 +129,9 @@ def instructions_from_instruments( return instructions -def loop_tick_from_instruments(instruments: Sequence[InstrumentExport]) -> Optional[int]: +def loop_tick_from_instruments( + instruments: Sequence[InstrumentExport], +) -> Optional[int]: """The tick a request's song returns to once it ends. A song repeats from its first tick where every slice it carries repeats, and ends at its diff --git a/src/sampletones_player/clock/schedule.py b/src/sampletones_player/clock/schedule.py index 82ee27363..a7dc6d064 100644 --- a/src/sampletones_player/clock/schedule.py +++ b/src/sampletones_player/clock/schedule.py @@ -130,7 +130,10 @@ def fixed_point_step(self) -> FixedPointStep: Every answer the schedule gives counts in this step, so it is computed once and held. """ - whole, fraction = divmod(round(self.ticks_per_play_call * FIXED_POINT_SCALE), FIXED_POINT_SCALE) + whole, fraction = divmod( + round(self.ticks_per_play_call * FIXED_POINT_SCALE), + FIXED_POINT_SCALE, + ) return FixedPointStep(whole=whole, fraction=fraction) def maximum_drift(self, play_calls: int) -> int: diff --git a/src/sampletones_player/compression/admit.py b/src/sampletones_player/compression/admit.py index a182b8a07..027593174 100644 --- a/src/sampletones_player/compression/admit.py +++ b/src/sampletones_player/compression/admit.py @@ -1,13 +1,25 @@ from typing import Final, List, NamedTuple, Sequence, Set, Tuple -from sampletones_player.compression.dictionary.phrase import Phrase, phrase_entry_size -from sampletones_player.compression.dictionary.table import PhraseTable, phrase_table -from sampletones_player.compression.matches.cache import MIN_PHRASE_TICKS, MatchCache +from sampletones_player.compression.dictionary.phrase import ( + Phrase, + phrase_entry_size, +) +from sampletones_player.compression.dictionary.table import ( + PhraseTable, + phrase_table, +) +from sampletones_player.compression.matches.cache import ( + MIN_PHRASE_TICKS, + MatchCache, +) from sampletones_player.compression.matches.shift import NO_SHIFT, asked_shift from sampletones_player.compression.options import CodecOptions from sampletones_player.compression.parse.result import Parse from sampletones_player.compression.tokens.sizes import phrase_size -from sampletones_player.specification.compression import MAX_PHRASE_IDS, PHRASE_ID_ESCAPE +from sampletones_player.specification.compression import ( + MAX_PHRASE_IDS, + PHRASE_ID_ESCAPE, +) from sampletones_shared.logger import logger CROWDED_PHRASE_ID: Final[int] = PHRASE_ID_ESCAPE diff --git a/src/sampletones_player/compression/compressed.py b/src/sampletones_player/compression/compressed.py index 82f47ede0..eb7eb4788 100644 --- a/src/sampletones_player/compression/compressed.py +++ b/src/sampletones_player/compression/compressed.py @@ -34,7 +34,9 @@ def _validate_the_song_lasts(self) -> CompressedPlanes: return self @model_validator(mode="after") - def _validate_every_stream_states_where_it_is_re_entered(self) -> CompressedPlanes: + def _validate_every_stream_states_where_it_is_re_entered( + self, + ) -> CompressedPlanes: if len(self.loop_entries) != len(self.streams): raise ValueError( f"an entry stands for each of the {len(self.streams)} streams, " f"and {len(self.loop_entries)} stand" diff --git a/src/sampletones_player/compression/encode.py b/src/sampletones_player/compression/encode.py index fca22584c..f8620b982 100644 --- a/src/sampletones_player/compression/encode.py +++ b/src/sampletones_player/compression/encode.py @@ -3,11 +3,17 @@ from typing import Dict, Final, FrozenSet, Iterable, List, Sequence, Tuple from sampletones_player.compression.admit import admit_seeds -from sampletones_player.compression.budget import DEFAULT_SEARCH_BUDGET, SearchBudget +from sampletones_player.compression.budget import ( + DEFAULT_SEARCH_BUDGET, + SearchBudget, +) from sampletones_player.compression.compressed import CompressedPlanes from sampletones_player.compression.dictionary.phrase import Phrase from sampletones_player.compression.dictionary.prune import prune -from sampletones_player.compression.dictionary.table import PhraseTable, phrase_table +from sampletones_player.compression.dictionary.table import ( + PhraseTable, + phrase_table, +) from sampletones_player.compression.entries import stream_entry from sampletones_player.compression.matches.cache import MatchCache from sampletones_player.compression.matches.index import PlaneIndex @@ -16,7 +22,10 @@ from sampletones_player.compression.parse.song import parse_planes from sampletones_player.compression.planes.order import PlaneOrder from sampletones_player.compression.planes.song import SongPlanes -from sampletones_player.compression.planes.symbols import pack_plane, symbol_boundaries +from sampletones_player.compression.planes.symbols import ( + pack_plane, + symbol_boundaries, +) from sampletones_player.compression.progress.monitor import CodecMonitor from sampletones_player.compression.progress.report import ( CodecReporter, @@ -272,7 +281,7 @@ def encode_planes( positions = [planes.positions(tick) for tick in boundaries] entries = [frozenset(position[index] for position in positions) for index in range(len(planes.planes))] packed = tuple( - b"" if plane.idles(played) else pack_plane(played, plane.form, boundaries=entry) + (b"" if plane.idles(played) else pack_plane(played, plane.form, boundaries=entry)) for plane, played, entry in zip(PLANES, planes.planes, entries, strict=True) ) table, streams = encode_streams( @@ -288,14 +297,19 @@ def encode_planes( ) returned = [ symbol_boundaries(played, plane.form, boundaries=frozenset({position})) - for plane, played, position in zip(PLANES, planes.planes, planes.positions(_returned(boundaries)), strict=True) + for plane, played, position in zip( + PLANES, + planes.planes, + planes.positions(_returned(boundaries)), + strict=True, + ) ] return CompressedPlanes( phrases=table, streams=PlaneOrder.across(streams), ticks=planes.ticks, loop_entries=tuple( - stream_entry(stream, next(iter(entry), STREAM_START), table) if stream else None + (stream_entry(stream, next(iter(entry), STREAM_START), table) if stream else None) for stream, entry in zip(streams, returned, strict=True) ), ) diff --git a/src/sampletones_player/compression/matches/cache.py b/src/sampletones_player/compression/matches/cache.py index e844a6d3b..1883000a0 100644 --- a/src/sampletones_player/compression/matches/cache.py +++ b/src/sampletones_player/compression/matches/cache.py @@ -5,7 +5,11 @@ from sampletones_player.compression.matches.index import PlaneIndex from sampletones_player.compression.matches.played import played_ticks from sampletones_player.compression.matches.reading import PhraseReading -from sampletones_player.compression.matches.shift import NO_SHIFT, asked_shift, translation +from sampletones_player.compression.matches.shift import ( + NO_SHIFT, + asked_shift, + translation, +) from sampletones_player.specification.compression import MAX_PHRASE_TICKS KEY_LENGTH: Final[int] = 2 diff --git a/src/sampletones_player/compression/matches/matcher.py b/src/sampletones_player/compression/matches/matcher.py index 9c2fd2604..c6ec842bb 100644 --- a/src/sampletones_player/compression/matches/matcher.py +++ b/src/sampletones_player/compression/matches/matcher.py @@ -2,7 +2,11 @@ from typing import Dict, Iterator, List, NamedTuple, Sequence, Tuple from sampletones_player.compression.dictionary.table import PhraseTable -from sampletones_player.compression.matches.cache import KEY_LENGTH, MIN_PHRASE_TICKS, MatchCache +from sampletones_player.compression.matches.cache import ( + KEY_LENGTH, + MIN_PHRASE_TICKS, + MatchCache, +) from sampletones_player.compression.matches.index import PlaneIndex from sampletones_player.specification.binary import BYTE_VALUES diff --git a/src/sampletones_player/compression/parse/plane.py b/src/sampletones_player/compression/parse/plane.py index 9ef8054a2..2f7a7b5a4 100644 --- a/src/sampletones_player/compression/parse/plane.py +++ b/src/sampletones_player/compression/parse/plane.py @@ -34,7 +34,12 @@ def _relax_literal( start = window.cheapest(position, earliest) cost = shortest.costs[start] + literal_size(position - start) if shortest.improves(position, cost): - shortest.relax(start, position, cost, LiteralToken(values=plane[start:position])) + shortest.relax( + start, + position, + cost, + LiteralToken(values=plane[start:position]), + ) def _relax_forward( @@ -71,7 +76,12 @@ def _relax_forward( position, position + ticks, cost + phrase_size(phrase_id, transpose, default=False), - PhraseToken(phrase_id=phrase_id, ticks=ticks, transpose=transpose, default=False), + PhraseToken( + phrase_id=phrase_id, + ticks=ticks, + transpose=transpose, + default=False, + ), ) carried = matcher.table[phrase_id].default if NO_DEFAULT_COUNT < carried <= ticks: @@ -79,7 +89,12 @@ def _relax_forward( position, position + carried, cost + phrase_size(phrase_id, transpose, default=True), - PhraseToken(phrase_id=phrase_id, ticks=carried, transpose=transpose, default=True), + PhraseToken( + phrase_id=phrase_id, + ticks=carried, + transpose=transpose, + default=True, + ), ) diff --git a/src/sampletones_player/compression/planes/flags.py b/src/sampletones_player/compression/planes/flags.py index 65d858cbb..92a641d83 100644 --- a/src/sampletones_player/compression/planes/flags.py +++ b/src/sampletones_player/compression/planes/flags.py @@ -1,6 +1,9 @@ from typing import List, Sequence, Tuple -from sampletones_player.specification.compression import BEND_FLAG, PITCH_INDEX_MASK +from sampletones_player.specification.compression import ( + BEND_FLAG, + PITCH_INDEX_MASK, +) def is_flagged(value: int) -> bool: diff --git a/src/sampletones_player/compression/planes/separate.py b/src/sampletones_player/compression/planes/separate.py index 779ae90fe..0563e9c06 100644 --- a/src/sampletones_player/compression/planes/separate.py +++ b/src/sampletones_player/compression/planes/separate.py @@ -2,7 +2,11 @@ from sampletones_core.constants.enums import TONE_CHANNELS, ChannelName from sampletones_player.compression.pitch import PitchTable -from sampletones_player.compression.planes.flags import flagged_value, is_flagged, note_flags +from sampletones_player.compression.planes.flags import ( + flagged_value, + is_flagged, + note_flags, +) from sampletones_player.compression.planes.order import PlaneOrder from sampletones_player.compression.planes.song import SongPlanes from sampletones_player.registers.base import ChannelRegisters diff --git a/src/sampletones_player/compression/progress/monitor.py b/src/sampletones_player/compression/progress/monitor.py index c38c31864..16bfcd6de 100644 --- a/src/sampletones_player/compression/progress/monitor.py +++ b/src/sampletones_player/compression/progress/monitor.py @@ -1,6 +1,9 @@ from typing import Final -from sampletones_player.compression.progress.report import CodecProgress, CodecReporter +from sampletones_player.compression.progress.report import ( + CodecProgress, + CodecReporter, +) from sampletones_shared.exceptions import OperationCanceled NOTHING_FOUND: Final[int] = 0 diff --git a/src/sampletones_player/compression/scheme.py b/src/sampletones_player/compression/scheme.py index 28d3814f0..7430b321a 100644 --- a/src/sampletones_player/compression/scheme.py +++ b/src/sampletones_player/compression/scheme.py @@ -27,7 +27,12 @@ def options(self) -> CodecOptions: """ match self: case CompressionScheme.NONE: - return CodecOptions(holds=False, phrases=False, transposition=False, search=False) + return CodecOptions( + holds=False, + phrases=False, + transposition=False, + search=False, + ) case CompressionScheme.RUNS: return CodecOptions(holds=True, phrases=False, transposition=False, search=False) case CompressionScheme.INSTRUMENTS: diff --git a/src/sampletones_player/compression/search.py b/src/sampletones_player/compression/search.py index 99aad6102..019d4b409 100644 --- a/src/sampletones_player/compression/search.py +++ b/src/sampletones_player/compression/search.py @@ -1,13 +1,32 @@ -from typing import Callable, Dict, Final, FrozenSet, Iterator, List, NamedTuple, Sequence, Tuple +from typing import ( + Callable, + Dict, + Final, + FrozenSet, + Iterator, + List, + NamedTuple, + Sequence, + Tuple, +) from sampletones_player.compression.budget import SearchBudget, shares -from sampletones_player.compression.dictionary.phrase import Phrase, phrase_entry_size -from sampletones_player.compression.dictionary.table import PhraseTable, phrase_table +from sampletones_player.compression.dictionary.phrase import ( + Phrase, + phrase_entry_size, +) +from sampletones_player.compression.dictionary.table import ( + PhraseTable, + phrase_table, +) from sampletones_player.compression.matches.cache import MatchCache from sampletones_player.compression.matches.index import PlaneIndex from sampletones_player.compression.options import CodecOptions from sampletones_player.compression.parse.result import Parse -from sampletones_player.compression.parse.song import parse_planes, parse_planes_offered +from sampletones_player.compression.parse.song import ( + parse_planes, + parse_planes_offered, +) from sampletones_player.compression.progress.monitor import CodecMonitor from sampletones_player.compression.tokens.literal import LiteralToken from sampletones_player.compression.tokens.sizes import phrase_size diff --git a/src/sampletones_player/driver/image.py b/src/sampletones_player/driver/image.py index 79cbe1d22..6343006a1 100644 --- a/src/sampletones_player/driver/image.py +++ b/src/sampletones_player/driver/image.py @@ -45,7 +45,10 @@ def _validate_the_song_follows_the_code(self) -> DriverImage: @model_validator(mode="after") def _validate_the_routines_lie_in_the_code(self) -> DriverImage: - for name, address in (("init", self.addresses.init), ("play", self.addresses.play)): + for name, address in ( + ("init", self.addresses.init), + ("play", self.addresses.play), + ): if not self.addresses.load <= address < self.addresses.song: raise ValueError( f"the {name} routine lies at {address:#06x}, outside the code between " @@ -56,7 +59,10 @@ def _validate_the_routines_lie_in_the_code(self) -> DriverImage: @model_validator(mode="after") def _validate_the_image_leads_with_its_entry_points(self) -> DriverImage: - for name, address in (("init", self.addresses.init), ("play", self.addresses.play)): + for name, address in ( + ("init", self.addresses.init), + ("play", self.addresses.play), + ): opcode = self.code[address - self.addresses.load] if opcode != JUMP_ABSOLUTE_OPCODE: raise ValueError( diff --git a/src/sampletones_player/export/reports.py b/src/sampletones_player/export/reports.py index 0e3067a6c..1ef30f9b8 100644 --- a/src/sampletones_player/export/reports.py +++ b/src/sampletones_player/export/reports.py @@ -1,7 +1,10 @@ from sampletones_core.exports.progress import ExportProgress, ExportReporter from sampletones_core.exports.stage import ExportStage from sampletones_core.performance import WalkProgress, WalkReporter -from sampletones_player.compression.progress.report import CodecProgress, CodecReporter +from sampletones_player.compression.progress.report import ( + CodecProgress, + CodecReporter, +) UNMEASURED: None = None diff --git a/src/sampletones_player/export/writer.py b/src/sampletones_player/export/writer.py index c66c7669d..a9987ba90 100644 --- a/src/sampletones_player/export/writer.py +++ b/src/sampletones_player/export/writer.py @@ -9,7 +9,11 @@ from sampletones_player.builder import song_from_project, song_from_sample from sampletones_player.driver.image import DriverImage from sampletones_player.export.program import NSFProgram -from sampletones_player.export.reports import UNMEASURED, codec_reporter, walk_reporter +from sampletones_player.export.reports import ( + UNMEASURED, + codec_reporter, + walk_reporter, +) from sampletones_player.nsf.file import write_nsf from sampletones_player.song import Song diff --git a/src/sampletones_player/nsf/layout.py b/src/sampletones_player/nsf/layout.py index bc1cdd944..39d6f5c5c 100644 --- a/src/sampletones_player/nsf/layout.py +++ b/src/sampletones_player/nsf/layout.py @@ -12,7 +12,10 @@ PHRASE_TABLE_COUNT_SIZE, PHRASE_TABLE_ENTRY_SIZE, ) -from sampletones_player.specification.song import ABSENT_STREAM, SONG_HEADER_SIZE +from sampletones_player.specification.song import ( + ABSENT_STREAM, + SONG_HEADER_SIZE, +) NAME_SEPARATOR: Final[str] = "_" diff --git a/src/sampletones_player/registers/pulse.py b/src/sampletones_player/registers/pulse.py index ddf9289cb..99eeb5c97 100644 --- a/src/sampletones_player/registers/pulse.py +++ b/src/sampletones_player/registers/pulse.py @@ -6,7 +6,10 @@ from sampletones_core.exporters.implementation.pulse import PulseExporter from sampletones_core.instructions import PulseInstruction -from sampletones_player.registers.dividers import anchored_pitches, bent_dividers +from sampletones_player.registers.dividers import ( + anchored_pitches, + bent_dividers, +) from sampletones_player.registers.hold import hold from sampletones_player.registers.tone import ToneRegisters from sampletones_player.specification.registers import ( diff --git a/src/sampletones_player/registers/triangle.py b/src/sampletones_player/registers/triangle.py index fe50da12b..ed1affd45 100644 --- a/src/sampletones_player/registers/triangle.py +++ b/src/sampletones_player/registers/triangle.py @@ -6,7 +6,10 @@ from sampletones_core.exporters.implementation.triangle import TriangleExporter from sampletones_core.instructions import TriangleInstruction -from sampletones_player.registers.dividers import anchored_pitches, bent_dividers +from sampletones_player.registers.dividers import ( + anchored_pitches, + bent_dividers, +) from sampletones_player.registers.hold import hold from sampletones_player.registers.tone import ToneRegisters from sampletones_player.specification.registers import ( @@ -52,7 +55,11 @@ def from_instructions( List[TriangleRegisters]: One register set per tick, including the closing release tick. """ _, pitches, volumes = TriangleExporter.extract_data(instructions) - dividers = bent_dividers(pitches, TriangleExporter.read_timer_offsets(instructions), timer_table) + dividers = bent_dividers( + pitches, + TriangleExporter.read_timer_offsets(instructions), + timer_table, + ) anchors = anchored_pitches(pitches, dividers, timer_table) registers: List[TriangleRegisters] = [] diff --git a/src/sampletones_player/specification/channels.py b/src/sampletones_player/specification/channels.py index 8c7c00cbd..9d86de7da 100644 --- a/src/sampletones_player/specification/channels.py +++ b/src/sampletones_player/specification/channels.py @@ -18,6 +18,10 @@ CHANNEL_REGISTER_ADDRESSES: Final[Dict[ChannelName, Tuple[int, ...]]] = { ChannelName.PULSE1: (PULSE1_CONTROL, PULSE1_TIMER_LOW, PULSE1_TIMER_HIGH), ChannelName.PULSE2: (PULSE2_CONTROL, PULSE2_TIMER_LOW, PULSE2_TIMER_HIGH), - ChannelName.TRIANGLE: (TRIANGLE_LINEAR_COUNTER, TRIANGLE_TIMER_LOW, TRIANGLE_TIMER_HIGH), + ChannelName.TRIANGLE: ( + TRIANGLE_LINEAR_COUNTER, + TRIANGLE_TIMER_LOW, + TRIANGLE_TIMER_HIGH, + ), ChannelName.NOISE: (NOISE_CONTROL, NOISE_PERIOD), } diff --git a/src/sampletones_player/specification/planes.py b/src/sampletones_player/specification/planes.py index 4db971e5e..e7e15087e 100644 --- a/src/sampletones_player/specification/planes.py +++ b/src/sampletones_player/specification/planes.py @@ -6,7 +6,11 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator from sampletones_core.constants.enums import ChannelName -from sampletones_core.constants.general import MAX_DUTY_CYCLE, MAX_PERIOD, MAX_VOLUME +from sampletones_core.constants.general import ( + MAX_DUTY_CYCLE, + MAX_PERIOD, + MAX_VOLUME, +) from sampletones_player.specification.binary import MAX_BYTE_VALUE from sampletones_player.specification.compression import PITCH_INDEX_MASK from sampletones_player.specification.registers import ( diff --git a/tests/unit/sampletones_player/clock/test_schedule.py b/tests/unit/sampletones_player/clock/test_schedule.py index e60e8b1a7..bb859f203 100644 --- a/tests/unit/sampletones_player/clock/test_schedule.py +++ b/tests/unit/sampletones_player/clock/test_schedule.py @@ -14,12 +14,27 @@ MAX_STEP_WHOLE, NTSC_FRAME_RATE, ) -from sampletones_shared.constants.nes import MAX_NES_FREQUENCY, MIN_NES_FREQUENCY +from sampletones_shared.constants.nes import ( + MAX_NES_FREQUENCY, + MIN_NES_FREQUENCY, +) from tests.suite.base import BaseTestSuite from tests.suite.case import BaseAutolabelTestCase LONG_RUN_PLAY_CALLS: Final[int] = 36000 -NES_FREQUENCIES: Final[Tuple[int, ...]] = (15, 24, 25, 30, 50, 60, 100, 120, 200, 299, 300) +NES_FREQUENCIES: Final[Tuple[int, ...]] = ( + 15, + 24, + 25, + 30, + 50, + 60, + 100, + 120, + 200, + 299, + 300, +) def exact_rate(nes_frequency: int) -> Fraction: @@ -149,7 +164,10 @@ def test_the_step_fields_match(self, test_case: TestCase) -> None: @pytest.mark.parametrize("nes_frequency", NES_FREQUENCIES) def test_the_value_recomposes_the_fields(self, nes_frequency: int) -> None: step = PlaySchedule.from_parameters(nes_frequency).fixed_point_step - assert divmod(step.value, FIXED_POINT_SCALE) == (step.whole, step.fraction) + assert divmod(step.value, FIXED_POINT_SCALE) == ( + step.whole, + step.fraction, + ) @pytest.mark.parametrize("nes_frequency", NES_FREQUENCIES) def test_the_step_is_the_nearest_unit_to_the_exact_rate(self, nes_frequency: int) -> None: @@ -189,9 +207,15 @@ def test_the_accumulator_reaches_the_same_ticks(self, nes_frequency: int) -> Non def test_a_whole_step_needs_no_fraction(self) -> None: step = PlaySchedule(ticks_per_play_call=Fraction(3)).fixed_point_step - assert (step.whole, step.fraction, step.value) == (3, 0, 3 * FIXED_POINT_SCALE) - - def test_a_step_a_hair_under_a_whole_tick_carries_into_the_whole_byte(self) -> None: + assert (step.whole, step.fraction, step.value) == ( + 3, + 0, + 3 * FIXED_POINT_SCALE, + ) + + def test_a_step_a_hair_under_a_whole_tick_carries_into_the_whole_byte( + self, + ) -> None: """Rounding the fraction up spills into the whole byte, keeping both fields in range.""" rate = Fraction(2) - Fraction(1, 10 * FIXED_POINT_SCALE) step = PlaySchedule(ticks_per_play_call=rate).fixed_point_step @@ -235,7 +259,9 @@ def test_a_negative_call_count_is_rejected(self) -> None: with pytest.raises(ValueError, match="play_calls must be at least 0"): schedule.ticks_at(-1) - def test_a_negative_call_count_is_rejected_by_the_exact_schedule(self) -> None: + def test_a_negative_call_count_is_rejected_by_the_exact_schedule( + self, + ) -> None: schedule = PlaySchedule.from_parameters(60) with pytest.raises(ValueError, match="play_calls must be at least 0"): schedule.exact_ticks_at(-1) diff --git a/tests/unit/sampletones_player/clock/test_step.py b/tests/unit/sampletones_player/clock/test_step.py index 35572dbef..6c64ba496 100644 --- a/tests/unit/sampletones_player/clock/test_step.py +++ b/tests/unit/sampletones_player/clock/test_step.py @@ -32,7 +32,10 @@ def label(self) -> str: TestCase(fields=(0, MAX_STEP_FRACTION), expected=MAX_STEP_FRACTION), TestCase(fields=(1, 0), expected=FIXED_POINT_SCALE), TestCase(fields=(3, 21837), expected=3 * FIXED_POINT_SCALE + 21837), - TestCase(fields=(MAX_STEP_WHOLE, MAX_STEP_FRACTION), expected=FIXED_POINT_SCALE * (MAX_STEP_WHOLE + 1) - 1), + TestCase( + fields=(MAX_STEP_WHOLE, MAX_STEP_FRACTION), + expected=FIXED_POINT_SCALE * (MAX_STEP_WHOLE + 1) - 1, + ), ) @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) @@ -44,7 +47,10 @@ def test_the_value_composes_the_fields(self, test_case: TestCase) -> None: def test_the_fields_read_back_off_the_value(self, test_case: TestCase) -> None: whole, fraction = test_case.fields step = FixedPointStep(whole=whole, fraction=fraction) - assert divmod(step.value, FIXED_POINT_SCALE) == (step.whole, step.fraction) + assert divmod(step.value, FIXED_POINT_SCALE) == ( + step.whole, + step.fraction, + ) @pytest.mark.parametrize("fraction", (-1, FIXED_POINT_SCALE)) def test_a_fraction_outside_the_word_is_rejected(self, fraction: int) -> None: diff --git a/tests/unit/sampletones_player/compression/dictionary/test_phrase.py b/tests/unit/sampletones_player/compression/dictionary/test_phrase.py index 255a0af71..a18fc934b 100644 --- a/tests/unit/sampletones_player/compression/dictionary/test_phrase.py +++ b/tests/unit/sampletones_player/compression/dictionary/test_phrase.py @@ -17,13 +17,17 @@ class TestAPhraseIsAShapeRatherThanValues: def test_the_steps_read_the_body_pairwise(self) -> None: assert phrase(10, 12, 9).differences == bytes((2, 253)) - def test_a_shifted_phrase_keeps_the_steps_of_the_one_it_was_stored_from(self) -> None: + def test_a_shifted_phrase_keeps_the_steps_of_the_one_it_was_stored_from( + self, + ) -> None: assert phrase(70, 72, 69).differences == phrase(10, 12, 9).differences def test_a_phrase_covers_the_ticks_its_body_states(self) -> None: assert phrase(1, 2, 3).length == 3 - def test_a_phrase_costs_its_entry_its_length_its_count_and_its_body(self) -> None: + def test_a_phrase_costs_its_entry_its_length_its_count_and_its_body( + self, + ) -> None: assert phrase(1, 2, 3).size == PHRASE_TABLE_ENTRY_SIZE + PHRASE_LENGTH_SIZE + PHRASE_DEFAULT_SIZE + 3 def test_a_body_reaching_past_a_length_byte_is_refused(self) -> None: diff --git a/tests/unit/sampletones_player/compression/dictionary/test_table.py b/tests/unit/sampletones_player/compression/dictionary/test_table.py index ef6727a1a..73c85e8d8 100644 --- a/tests/unit/sampletones_player/compression/dictionary/test_table.py +++ b/tests/unit/sampletones_player/compression/dictionary/test_table.py @@ -1,12 +1,18 @@ import pytest from pydantic import ValidationError -from sampletones_player.compression.dictionary.table import PhraseTable, phrase_table +from sampletones_player.compression.dictionary.table import ( + PhraseTable, + phrase_table, +) from sampletones_player.specification.compression import ( MAX_PHRASE_IDS, PHRASE_TABLE_COUNT_SIZE, ) -from tests.unit.sampletones_player.compression.dictionary.phrases import distinct, phrase +from tests.unit.sampletones_player.compression.dictionary.phrases import ( + distinct, + phrase, +) class TestTheTableHoldsWhatATokenCanName: diff --git a/tests/unit/sampletones_player/compression/matches/test_matcher.py b/tests/unit/sampletones_player/compression/matches/test_matcher.py index e5d70ba66..9e041ce26 100644 --- a/tests/unit/sampletones_player/compression/matches/test_matcher.py +++ b/tests/unit/sampletones_player/compression/matches/test_matcher.py @@ -4,7 +4,10 @@ from sampletones_player.compression.dictionary.table import phrase_table from sampletones_player.compression.matches.cache import KEY_LENGTH, MatchCache from sampletones_player.compression.matches.index import PlaneIndex -from sampletones_player.compression.matches.matcher import PhraseMatch, PhraseMatcher +from sampletones_player.compression.matches.matcher import ( + PhraseMatch, + PhraseMatcher, +) from sampletones_player.specification.compression import MAX_PHRASE_TICKS MOTIF: Final[bytes] = bytes((40, 44, 47)) @@ -42,11 +45,15 @@ def test_a_note_outlasting_its_phrase_holds_the_phrase_out(self) -> None: plane = MOTIF + bytes((MOTIF[-1],)) * 4 assert found(plane, (Phrase(body=MOTIF),), 0) == [PhraseMatch(phrase_id=0, ticks=len(plane), transpose=0)] - def test_a_note_cut_short_plays_as_much_of_the_phrase_as_sounded(self) -> None: + def test_a_note_cut_short_plays_as_much_of_the_phrase_as_sounded( + self, + ) -> None: plane = MOTIF[:3] + bytes((99,)) assert found(plane, (Phrase(body=MOTIF),), 0) == [PhraseMatch(phrase_id=0, ticks=3, transpose=0)] - def test_a_note_cut_before_its_shape_is_told_apart_is_offered_nowhere(self) -> None: + def test_a_note_cut_before_its_shape_is_told_apart_is_offered_nowhere( + self, + ) -> None: """A phrase is shortlisted by its first steps, so a shorter start names no phrase.""" plane = MOTIF[:KEY_LENGTH] + bytes((99,)) assert found(plane, (Phrase(body=MOTIF),), 0) == [] diff --git a/tests/unit/sampletones_player/compression/parse/test_boundaries.py b/tests/unit/sampletones_player/compression/parse/test_boundaries.py index d9b7dc1f3..13ad6b0ad 100644 --- a/tests/unit/sampletones_player/compression/parse/test_boundaries.py +++ b/tests/unit/sampletones_player/compression/parse/test_boundaries.py @@ -24,6 +24,8 @@ def test_a_tick_looks_forward_to_the_boundary_ahead_of_it(self) -> None: boundaries = Boundaries.across(TICKS, ENTRIES) assert boundaries.following[0] == 4 - def test_a_tick_past_the_last_boundary_looks_forward_to_the_end_of_the_plane(self) -> None: + def test_a_tick_past_the_last_boundary_looks_forward_to_the_end_of_the_plane( + self, + ) -> None: boundaries = Boundaries.across(TICKS, ENTRIES) assert boundaries.following[4] == TICKS diff --git a/tests/unit/sampletones_player/compression/parse/test_plane.py b/tests/unit/sampletones_player/compression/parse/test_plane.py index 6c1abf2f8..1f8490a63 100644 --- a/tests/unit/sampletones_player/compression/parse/test_plane.py +++ b/tests/unit/sampletones_player/compression/parse/test_plane.py @@ -12,7 +12,10 @@ from sampletones_player.compression.tokens.literal import LiteralToken from sampletones_player.compression.tokens.phrase import PhraseToken from sampletones_player.compression.tokens.types import TokenUnion -from sampletones_player.specification.compression import MAX_HOLD_TICKS, MAX_LITERAL_BYTES +from sampletones_player.specification.compression import ( + MAX_HOLD_TICKS, + MAX_LITERAL_BYTES, +) EVERY_LAYER: Final[CodecOptions] = CodecOptions( holds=True, @@ -63,15 +66,22 @@ def test_the_tokens_cover_every_tick_of_the_plane(self) -> None: parse = parsed(plane) assert sum(token.ticks for token in parse.tokens) == len(plane) - def test_the_cost_of_the_whole_plane_is_the_cost_of_its_tokens(self) -> None: + def test_the_cost_of_the_whole_plane_is_the_cost_of_its_tokens( + self, + ) -> None: parse = parsed(bytes((1, 1, 2, 2, 2, 9))) assert parse.size == sum(token.size for token in parse.tokens) def test_a_run_reaches_the_stream_as_a_hold(self) -> None: parse = parsed(bytes((7,)) * 40) - assert parse.tokens == (LiteralToken(values=bytes((7,))), HoldToken(ticks=39)) + assert parse.tokens == ( + LiteralToken(values=bytes((7,))), + HoldToken(ticks=39), + ) - def test_a_run_longer_than_one_hold_reaches_the_stream_as_several(self) -> None: + def test_a_run_longer_than_one_hold_reaches_the_stream_as_several( + self, + ) -> None: parse = parsed(bytes((7,)) * (2 * MAX_HOLD_TICKS + 1)) assert parse.tokens[1:] == (HoldToken(ticks=MAX_HOLD_TICKS),) * 2 @@ -83,7 +93,9 @@ def test_a_plane_the_codec_finds_nothing_in_spells_itself_out(self) -> None: LiteralToken(values=plane[MAX_LITERAL_BYTES:]), ) - def test_a_figure_the_dictionary_holds_reaches_the_stream_as_a_phrase(self) -> None: + def test_a_figure_the_dictionary_holds_reaches_the_stream_as_a_phrase( + self, + ) -> None: parse = parsed(MOTIF, (Phrase(body=MOTIF),)) assert parse.tokens == (PhraseToken(phrase_id=0, ticks=len(MOTIF), transpose=0, default=False),) @@ -92,7 +104,9 @@ def test_the_same_figure_played_higher_names_the_same_phrase(self) -> None: parse = parsed(higher, (Phrase(body=MOTIF),)) assert parse.tokens == (PhraseToken(phrase_id=0, ticks=len(MOTIF), transpose=7, default=False),) - def test_a_phrase_the_layer_switches_off_is_spelled_out_instead(self) -> None: + def test_a_phrase_the_layer_switches_off_is_spelled_out_instead( + self, + ) -> None: parse = parsed(MOTIF, (Phrase(body=MOTIF),), options=LITERALS_ONLY) assert parse.tokens == (LiteralToken(values=MOTIF),) @@ -105,7 +119,9 @@ def test_a_token_starts_on_every_boundary(self) -> None: parse = parsed(plane, boundaries=frozenset({0, 7, 13})) assert {0, 7, 13} <= set(starts(parse)) - def test_a_boundary_is_reached_by_a_token_stating_its_own_value(self) -> None: + def test_a_boundary_is_reached_by_a_token_stating_its_own_value( + self, + ) -> None: """A hold plays the value the plane already reached, which a re-entry has yet to state.""" plane = bytes((3,)) * 20 parse = parsed(plane, boundaries=frozenset({0, 7})) diff --git a/tests/unit/sampletones_player/compression/planes/test_flags.py b/tests/unit/sampletones_player/compression/planes/test_flags.py index f0780ba51..842b6b064 100644 --- a/tests/unit/sampletones_player/compression/planes/test_flags.py +++ b/tests/unit/sampletones_player/compression/planes/test_flags.py @@ -76,6 +76,8 @@ class TestCase(BaseRegularTestCase): def test_the_flags_match(self, test_case: TestCase) -> None: assert note_flags(test_case.indices, test_case.offsets) == test_case.expected - def test_indices_and_offsets_covering_different_ticks_are_refused(self) -> None: + def test_indices_and_offsets_covering_different_ticks_are_refused( + self, + ) -> None: with pytest.raises(ValueError): note_flags((INDEX,), ()) diff --git a/tests/unit/sampletones_player/compression/planes/test_order.py b/tests/unit/sampletones_player/compression/planes/test_order.py index c2a9583a9..3df08237d 100644 --- a/tests/unit/sampletones_player/compression/planes/test_order.py +++ b/tests/unit/sampletones_player/compression/planes/test_order.py @@ -11,11 +11,15 @@ def numbered(count: int) -> PlaneOrder: class TestThePlanesAreNamedRatherThanNumbered: """The song block writes its planes in one order, and each is reached by its own name.""" - def test_the_planes_take_the_names_the_song_block_writes_them_by(self) -> None: + def test_the_planes_take_the_names_the_song_block_writes_them_by( + self, + ) -> None: planes = numbered(PLANE_COUNT) assert planes.pulse1_control == bytes((0,)) assert planes.noise_value == bytes((PLANE_COUNT - 1,)) - def test_a_run_of_planes_other_than_a_song_block_holds_is_refused(self) -> None: + def test_a_run_of_planes_other_than_a_song_block_holds_is_refused( + self, + ) -> None: with pytest.raises(ValueError): numbered(PLANE_COUNT - 1) diff --git a/tests/unit/sampletones_player/compression/planes/test_rebuild.py b/tests/unit/sampletones_player/compression/planes/test_rebuild.py index 82443b5bb..4f5ddf563 100644 --- a/tests/unit/sampletones_player/compression/planes/test_rebuild.py +++ b/tests/unit/sampletones_player/compression/planes/test_rebuild.py @@ -3,8 +3,16 @@ from sampletones_player.compression.planes.rebuild import streams_from_planes from sampletones_player.compression.planes.separate import planes_from_streams from sampletones_player.registers.streams import ChannelStreams -from tests.suite.player import PLAYER_FULL_VOLUME, noise_tick, player_streams, pulse_tick, triangle_tick -from tests.unit.sampletones_player.compression.planes.conftest import NOISE_PERIOD +from tests.suite.player import ( + PLAYER_FULL_VOLUME, + noise_tick, + player_streams, + pulse_tick, + triangle_tick, +) +from tests.unit.sampletones_player.compression.planes.conftest import ( + NOISE_PERIOD, +) class TestThePlanesReadBackAsTheStreamsTheyCameFrom: diff --git a/tests/unit/sampletones_player/compression/planes/test_separate.py b/tests/unit/sampletones_player/compression/planes/test_separate.py index e167dd2fa..09e83fc99 100644 --- a/tests/unit/sampletones_player/compression/planes/test_separate.py +++ b/tests/unit/sampletones_player/compression/planes/test_separate.py @@ -5,11 +5,20 @@ from sampletones_core.constants.enums import ChannelName from sampletones_player.compression.pitch import PitchTable from sampletones_player.compression.planes.flags import flagged_value -from sampletones_player.compression.planes.separate import channel_planes, planes_from_streams +from sampletones_player.compression.planes.separate import ( + channel_planes, + planes_from_streams, +) from sampletones_player.registers.pulse import PulseRegisters from sampletones_player.registers.streams import ChannelStreams -from sampletones_player.specification.binary import SIGNED_BYTE_LIMIT, unsigned_byte -from sampletones_player.specification.registers import MAX_REGISTER_VALUE, TIMER_HIGH_SHIFT +from sampletones_player.specification.binary import ( + SIGNED_BYTE_LIMIT, + unsigned_byte, +) +from sampletones_player.specification.registers import ( + MAX_REGISTER_VALUE, + TIMER_HIGH_SHIFT, +) from tests.suite.player import PLAYER_FULL_VOLUME, resting_streams from tests.unit.sampletones_player.compression.planes.conftest import ( HIGH_INDEX, diff --git a/tests/unit/sampletones_player/compression/planes/test_song.py b/tests/unit/sampletones_player/compression/planes/test_song.py index cac23061d..c58cc47ff 100644 --- a/tests/unit/sampletones_player/compression/planes/test_song.py +++ b/tests/unit/sampletones_player/compression/planes/test_song.py @@ -28,7 +28,9 @@ class TestASongGathersEveryPlaneItsBlockWrites: def test_a_song_carries_the_planes_a_block_writes(self) -> None: assert len(sounding_planes(TIMBRE, FLAGGED, BEND).planes) == PLANE_COUNT - def test_a_plane_is_reached_by_the_name_the_block_writes_it_under(self) -> None: + def test_a_plane_is_reached_by_the_name_the_block_writes_it_under( + self, + ) -> None: planes = sounding_planes(TIMBRE, FLAGGED, BEND).planes assert planes.pulse1_bend == BEND @@ -60,18 +62,35 @@ def test_a_bend_for_every_flagged_tick_is_taken(self) -> None: assert sounding_planes(TIMBRE, value, BEND[:1]).planes.pulse1_bend == BEND[:1] - def test_a_bend_plane_holding_more_than_the_flags_ask_for_is_refused(self) -> None: + def test_a_bend_plane_holding_more_than_the_flags_ask_for_is_refused( + self, + ) -> None: with pytest.raises(ValidationError): sounding_planes(TIMBRE, bytes((3, 4)), BEND) - def test_a_bend_plane_holding_fewer_than_the_flags_ask_for_is_refused(self) -> None: + def test_a_bend_plane_holding_fewer_than_the_flags_ask_for_is_refused( + self, + ) -> None: with pytest.raises(ValidationError): sounding_planes(TIMBRE, FLAGGED, BEND[:1]) - def test_a_song_returning_to_a_tick_re_enters_the_bend_plane_past_the_flags_before_it(self) -> None: - value = bytes((flagged_value(3, True), flagged_value(3, False), flagged_value(3, True))) + def test_a_song_returning_to_a_tick_re_enters_the_bend_plane_past_the_flags_before_it( + self, + ) -> None: + value = bytes( + ( + flagged_value(3, True), + flagged_value(3, False), + flagged_value(3, True), + ) + ) planes = sounding_planes(TIMBRE + bytes((0x30,)), value, BEND) - assert [planes.positions(tick)[PULSE1_BEND] for tick in range(4)] == [0, 1, 1, 2] + assert [planes.positions(tick)[PULSE1_BEND] for tick in range(4)] == [ + 0, + 1, + 1, + 2, + ] dense = [position for name, position in zip(PlaneOrder.names(), planes.positions(2)) if name not in BENDS] assert dense == [2] * len(dense) diff --git a/tests/unit/sampletones_player/compression/test_admit.py b/tests/unit/sampletones_player/compression/test_admit.py index 9a6d360bb..ee0eec0d8 100644 --- a/tests/unit/sampletones_player/compression/test_admit.py +++ b/tests/unit/sampletones_player/compression/test_admit.py @@ -123,4 +123,8 @@ def test_the_run_says_the_table_could_not_take_them_all( def crowded_seeds() -> Tuple[Phrase, ...]: """More shapes than a token can name, two of which the plane actually plays.""" - return (*unplayed_seeds(), Phrase(body=LEANED_ON), Phrase(body=PLAYED_SELDOM)) + return ( + *unplayed_seeds(), + Phrase(body=LEANED_ON), + Phrase(body=PLAYED_SELDOM), + ) diff --git a/tests/unit/sampletones_player/compression/test_budget.py b/tests/unit/sampletones_player/compression/test_budget.py index e39a02db0..d4d0f7225 100644 --- a/tests/unit/sampletones_player/compression/test_budget.py +++ b/tests/unit/sampletones_player/compression/test_budget.py @@ -4,7 +4,11 @@ import pytest from pydantic import ValidationError -from sampletones_player.compression.budget import DEFAULT_SEARCH_BUDGET, SearchBudget, shares +from sampletones_player.compression.budget import ( + DEFAULT_SEARCH_BUDGET, + SearchBudget, + shares, +) from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase @@ -19,11 +23,36 @@ class TestCase(BaseRegularTestCase): expected: Tuple[int, ...] test_cases = ( - TestCase(label="every_demand_fits", demands=(5, 5, 5), total=30, expected=(5, 5, 5)), - TestCase(label="a_small_demand_leaves_its_surplus", demands=(100, 1, 100), total=41, expected=(20, 1, 20)), - TestCase(label="a_zero_demand_takes_nothing", demands=(0, 7), total=3, expected=(0, 3)), - TestCase(label="equal_demands_split_evenly", demands=(9, 9), total=8, expected=(4, 4)), - TestCase(label="a_total_short_of_one_each", demands=(9, 9, 9), total=2, expected=(0, 1, 1)), + TestCase( + label="every_demand_fits", + demands=(5, 5, 5), + total=30, + expected=(5, 5, 5), + ), + TestCase( + label="a_small_demand_leaves_its_surplus", + demands=(100, 1, 100), + total=41, + expected=(20, 1, 20), + ), + TestCase( + label="a_zero_demand_takes_nothing", + demands=(0, 7), + total=3, + expected=(0, 3), + ), + TestCase( + label="equal_demands_split_evenly", + demands=(9, 9), + total=8, + expected=(4, 4), + ), + TestCase( + label="a_total_short_of_one_each", + demands=(9, 9, 9), + total=2, + expected=(0, 1, 1), + ), TestCase(label="no_claimants", demands=(), total=10, expected=()), ) diff --git a/tests/unit/sampletones_player/compression/test_decode.py b/tests/unit/sampletones_player/compression/test_decode.py index 9875ed8fc..14ea7fdbf 100644 --- a/tests/unit/sampletones_player/compression/test_decode.py +++ b/tests/unit/sampletones_player/compression/test_decode.py @@ -4,8 +4,16 @@ from sampletones_player.compression.decode import decode_plane from sampletones_player.compression.dictionary.phrase import Phrase from sampletones_player.compression.dictionary.table import phrase_table -from sampletones_player.specification.compression import PHRASE_ID_ESCAPE, TokenTag -from sampletones_player.specification.planes import PLANES, Plane, PlaneRole, plane_index +from sampletones_player.specification.compression import ( + PHRASE_ID_ESCAPE, + TokenTag, +) +from sampletones_player.specification.planes import ( + PLANES, + Plane, + PlaneRole, + plane_index, +) MOTIF: Final[bytes] = bytes((40, 44, 47)) SPELLED_PLANE: Final[Plane] = PLANES[plane_index(ChannelName.PULSE1, PlaneRole.VALUE)] @@ -27,11 +35,15 @@ def test_a_phrase_writes_the_body_the_table_holds(self) -> None: stream = bytes((TokenTag.PHRASE | 0, len(MOTIF) - 1)) assert decode_plane(stream, DICTIONARY, SPELLED_PLANE, len(MOTIF)) == MOTIF - def test_a_phrase_running_past_its_body_holds_the_value_it_ended_on(self) -> None: + def test_a_phrase_running_past_its_body_holds_the_value_it_ended_on( + self, + ) -> None: stream = bytes((TokenTag.PHRASE | 0, len(MOTIF) + 1)) assert decode_plane(stream, DICTIONARY, SPELLED_PLANE, len(MOTIF) + 2) == MOTIF + bytes((MOTIF[-1],)) * 2 - def test_a_phrase_cut_short_writes_as_much_of_the_body_as_sounded(self) -> None: + def test_a_phrase_cut_short_writes_as_much_of_the_body_as_sounded( + self, + ) -> None: stream = bytes((TokenTag.PHRASE | 0, 1)) assert decode_plane(stream, DICTIONARY, SPELLED_PLANE, 2) == MOTIF[:2] @@ -39,18 +51,30 @@ def test_a_shifted_phrase_writes_the_body_moved_by_the_shift(self) -> None: stream = bytes((TokenTag.TRANSPOSED_PHRASE | 0, len(MOTIF) - 1, 5)) assert decode_plane(stream, DICTIONARY, SPELLED_PLANE, len(MOTIF)) == bytes(value + 5 for value in MOTIF) - def test_a_shift_walks_the_byte_around_where_it_reaches_past_one(self) -> None: + def test_a_shift_walks_the_byte_around_where_it_reaches_past_one( + self, + ) -> None: """The driver adds the shift to a byte, so a fall reaches it as the byte that wraps to it.""" stream = bytes((TokenTag.TRANSPOSED_PHRASE | 0, 0, 0xFD)) assert decode_plane(stream, DICTIONARY, SPELLED_PLANE, 1) == bytes((MOTIF[0] - 3,)) - def test_an_escaped_phrase_names_its_id_in_the_byte_that_follows(self) -> None: + def test_an_escaped_phrase_names_its_id_in_the_byte_that_follows( + self, + ) -> None: table = phrase_table( tuple(Phrase(body=bytes((value, value))) for value in range(PHRASE_ID_ESCAPE)) + (Phrase(body=MOTIF),) ) - stream = bytes((TokenTag.PHRASE | PHRASE_ID_ESCAPE, PHRASE_ID_ESCAPE, len(MOTIF) - 1)) + stream = bytes( + ( + TokenTag.PHRASE | PHRASE_ID_ESCAPE, + PHRASE_ID_ESCAPE, + len(MOTIF) - 1, + ) + ) assert decode_plane(stream, table, SPELLED_PLANE, len(MOTIF)) == MOTIF - def test_a_token_reaching_past_the_song_stops_where_the_song_does(self) -> None: + def test_a_token_reaching_past_the_song_stops_where_the_song_does( + self, + ) -> None: stream = bytes((TokenTag.LITERAL | 0, 4, TokenTag.HOLD | 63)) assert decode_plane(stream, DICTIONARY, SPELLED_PLANE, 3) == bytes((4, 4, 4)) diff --git a/tests/unit/sampletones_player/compression/test_encode.py b/tests/unit/sampletones_player/compression/test_encode.py index 787710ef8..866055739 100644 --- a/tests/unit/sampletones_player/compression/test_encode.py +++ b/tests/unit/sampletones_player/compression/test_encode.py @@ -59,7 +59,9 @@ def test_a_shifted_phrase_states_the_shift_after_the_count(self) -> None: token = PhraseToken(phrase_id=2, ticks=5, transpose=0xFD, default=False) assert emit((token,)) == bytes((TokenTag.TRANSPOSED_PHRASE | 2, 4, 0xFD)) - def test_a_phrase_beyond_the_cheap_ids_names_itself_in_the_byte_that_follows(self) -> None: + def test_a_phrase_beyond_the_cheap_ids_names_itself_in_the_byte_that_follows( + self, + ) -> None: token = PhraseToken(phrase_id=200, ticks=MAX_PHRASE_TICKS, transpose=0, default=False) assert emit((token,)) == bytes((TokenTag.PHRASE | PHRASE_ID_ESCAPE, 200, MAX_PHRASE_TICKS - 1)) @@ -111,7 +113,10 @@ def test_a_seed_the_song_never_leans_on_leaves_the_dictionary(self) -> None: def test_the_figure_played_most_takes_the_cheapest_id(self) -> None: planes = song_planes(bytes((0x30,)) * (len(MOTIF) * REPEATS), MOTIF * REPEATS) - seeds: Tuple[Phrase, ...] = (Phrase(body=bytes((0x30,)) * 8), Phrase(body=MOTIF)) + seeds: Tuple[Phrase, ...] = ( + Phrase(body=bytes((0x30,)) * 8), + Phrase(body=MOTIF), + ) compressed = encode_planes( planes, seeds, @@ -120,7 +125,9 @@ def test_the_figure_played_most_takes_the_cheapest_id(self) -> None: ) assert compressed.phrases[0].body == MOTIF - def test_a_song_re_entered_at_a_boundary_still_plays_back_whole(self) -> None: + def test_a_song_re_entered_at_a_boundary_still_plays_back_whole( + self, + ) -> None: planes = song_planes(TIMBRE * REPEATS, MOTIF * REPEATS) compressed = encode_planes( planes, diff --git a/tests/unit/sampletones_player/compression/test_entries.py b/tests/unit/sampletones_player/compression/test_entries.py index b784db58e..a901a0d72 100644 --- a/tests/unit/sampletones_player/compression/test_entries.py +++ b/tests/unit/sampletones_player/compression/test_entries.py @@ -2,7 +2,10 @@ import pytest -from sampletones_player.compression.dictionary.table import PhraseTable, phrase_table +from sampletones_player.compression.dictionary.table import ( + PhraseTable, + phrase_table, +) from sampletones_player.compression.encode import emit from sampletones_player.compression.entries import stream_entry from sampletones_player.compression.tokens.hold import HoldToken @@ -25,7 +28,11 @@ def test_the_first_tick_is_the_streams_own_first_byte(self) -> None: def test_a_tick_a_token_starts_answers_with_that_tokens_byte(self) -> None: literal = LiteralToken(values=b"\x01\x02") stream = emit( - [literal, HoldToken(ticks=3), PhraseToken(phrase_id=PHRASE_ID, ticks=4, transpose=0, default=False)] + [ + literal, + HoldToken(ticks=3), + PhraseToken(phrase_id=PHRASE_ID, ticks=4, transpose=0, default=False), + ] ) assert stream_entry(stream, literal.ticks, DICTIONARY) == literal.size diff --git a/tests/unit/sampletones_player/compression/test_scheme.py b/tests/unit/sampletones_player/compression/test_scheme.py index 53ce37a8c..644d0b842 100644 --- a/tests/unit/sampletones_player/compression/test_scheme.py +++ b/tests/unit/sampletones_player/compression/test_scheme.py @@ -3,11 +3,19 @@ import pytest from sampletones_player.compression.options import EVERY_LAYER, CodecOptions -from sampletones_player.compression.scheme import CompressionScheme, offered_schemes +from sampletones_player.compression.scheme import ( + CompressionScheme, + offered_schemes, +) def layers(options: CodecOptions) -> Tuple[bool, ...]: - return (options.holds, options.phrases, options.transposition, options.search) + return ( + options.holds, + options.phrases, + options.transposition, + options.search, + ) class TestCompressionScheme: @@ -40,7 +48,9 @@ class TestOfferedSchemes: def test_a_seeded_song_is_offered_every_scheme(self) -> None: assert offered_schemes(seeded=True) == tuple(CompressionScheme) - def test_a_song_seeding_nothing_is_offered_every_scheme_but_the_instruments(self) -> None: + def test_a_song_seeding_nothing_is_offered_every_scheme_but_the_instruments( + self, + ) -> None: assert offered_schemes(seeded=False) == tuple( scheme for scheme in CompressionScheme if scheme != CompressionScheme.INSTRUMENTS ) diff --git a/tests/unit/sampletones_player/compression/test_search.py b/tests/unit/sampletones_player/compression/test_search.py index 8f1970da4..300b322f0 100644 --- a/tests/unit/sampletones_player/compression/test_search.py +++ b/tests/unit/sampletones_player/compression/test_search.py @@ -4,7 +4,10 @@ import pytest from sampletones_player.compression.budget import SearchBudget -from sampletones_player.compression.dictionary.table import PhraseTable, phrase_table +from sampletones_player.compression.dictionary.table import ( + PhraseTable, + phrase_table, +) from sampletones_player.compression.matches.cache import MatchCache from sampletones_player.compression.matches.index import PlaneIndex from sampletones_player.compression.options import EVERY_LAYER @@ -63,9 +66,21 @@ class TestTheRoundsBoundWhatTheSearchEarns: """Each round adds at most one phrase, so the rounds cap the dictionary the search fills.""" def test_no_rounds_leaves_the_table_as_seeded(self) -> None: - table = _searched(SearchBudget(candidate_entries=NARROW_ENTRIES, rounds=0, confirmed_candidates=3)) + table = _searched( + SearchBudget( + candidate_entries=NARROW_ENTRIES, + rounds=0, + confirmed_candidates=3, + ) + ) assert len(table) == 0 def test_one_round_adds_one_phrase(self) -> None: - table = _searched(SearchBudget(candidate_entries=NARROW_ENTRIES, rounds=1, confirmed_candidates=3)) + table = _searched( + SearchBudget( + candidate_entries=NARROW_ENTRIES, + rounds=1, + confirmed_candidates=3, + ) + ) assert len(table) == 1 diff --git a/tests/unit/sampletones_player/compression/test_seeds.py b/tests/unit/sampletones_player/compression/test_seeds.py index 2fdb4e220..f6d074ebf 100644 --- a/tests/unit/sampletones_player/compression/test_seeds.py +++ b/tests/unit/sampletones_player/compression/test_seeds.py @@ -16,7 +16,11 @@ from sampletones_player.specification.binary import unsigned_byte from sampletones_player.specification.planes import PLANES from sampletones_shared.music import Tuning -from tests.suite.performance import make_pulse_reconstruction, project_with_instrument, project_with_sample +from tests.suite.performance import ( + make_pulse_reconstruction, + project_with_instrument, + project_with_sample, +) from tests.suite.player import PLAYER_FULL_VOLUME, PLAYER_REFERENCE_PITCH TUNING: Final[Tuning] = Tuning() diff --git a/tests/unit/sampletones_player/compression/test_song.py b/tests/unit/sampletones_player/compression/test_song.py index f59dd9e2c..c6a4dc87d 100644 --- a/tests/unit/sampletones_player/compression/test_song.py +++ b/tests/unit/sampletones_player/compression/test_song.py @@ -50,7 +50,9 @@ def test_every_tick_writes_the_registers_it_was_given(self) -> None: streams.at(tick) for tick in range(streams.ticks) ] - def test_a_channel_running_out_early_is_carried_to_the_songs_length(self) -> None: + def test_a_channel_running_out_early_is_carried_to_the_songs_length( + self, + ) -> None: streams = resting_streams((SOUNDING, OCTAVE_UP, RESTING)) rebuilt = played(streams, None) assert len(rebuilt.noise) == streams.ticks diff --git a/tests/unit/sampletones_player/compression/tokens/test_hold.py b/tests/unit/sampletones_player/compression/tokens/test_hold.py index 4e9a86220..af7f0b58d 100644 --- a/tests/unit/sampletones_player/compression/tokens/test_hold.py +++ b/tests/unit/sampletones_player/compression/tokens/test_hold.py @@ -1,5 +1,8 @@ from sampletones_player.compression.tokens.hold import HoldToken -from sampletones_player.specification.compression import MAX_HOLD_TICKS, OPCODE_SIZE +from sampletones_player.specification.compression import ( + MAX_HOLD_TICKS, + OPCODE_SIZE, +) class TestWhatAHoldCosts: diff --git a/tests/unit/sampletones_player/compression/tokens/test_literal.py b/tests/unit/sampletones_player/compression/tokens/test_literal.py index d78b237bf..ac708e21d 100644 --- a/tests/unit/sampletones_player/compression/tokens/test_literal.py +++ b/tests/unit/sampletones_player/compression/tokens/test_literal.py @@ -11,6 +11,8 @@ class TestWhatALiteralCosts: def test_a_literal_covers_a_tick_for_every_value_it_states(self) -> None: assert LiteralToken(values=bytes(SPELLED_OUT)).ticks == SPELLED_OUT - def test_a_literal_costs_its_opcode_and_the_values_it_spells_out(self) -> None: + def test_a_literal_costs_its_opcode_and_the_values_it_spells_out( + self, + ) -> None: token = LiteralToken(values=bytes(SPELLED_OUT)) assert token.size == literal_size(SPELLED_OUT) == OPCODE_SIZE + SPELLED_OUT diff --git a/tests/unit/sampletones_player/compression/tokens/test_phrase.py b/tests/unit/sampletones_player/compression/tokens/test_phrase.py index 515ef7d7a..6048c6cf3 100644 --- a/tests/unit/sampletones_player/compression/tokens/test_phrase.py +++ b/tests/unit/sampletones_player/compression/tokens/test_phrase.py @@ -51,5 +51,10 @@ def label(self) -> str: @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) def test_a_phrase_token_pays_for_the_id_and_the_shift_it_names(self, test_case: TestCase) -> None: - token = PhraseToken(phrase_id=test_case.phrase_id, ticks=1, transpose=test_case.transpose, default=False) + token = PhraseToken( + phrase_id=test_case.phrase_id, + ticks=1, + transpose=test_case.transpose, + default=False, + ) assert token.size == test_case.expected == phrase_size(test_case.phrase_id, test_case.transpose, default=False) diff --git a/tests/unit/sampletones_player/compression/tokens/test_span.py b/tests/unit/sampletones_player/compression/tokens/test_span.py index e362a86c0..3f4a5d94f 100644 --- a/tests/unit/sampletones_player/compression/tokens/test_span.py +++ b/tests/unit/sampletones_player/compression/tokens/test_span.py @@ -4,7 +4,10 @@ import pytest from sampletones_player.compression.dictionary.phrase import Phrase -from sampletones_player.compression.dictionary.table import PhraseTable, phrase_table +from sampletones_player.compression.dictionary.table import ( + PhraseTable, + phrase_table, +) from sampletones_player.compression.encode import emit from sampletones_player.compression.tokens.hold import HoldToken from sampletones_player.compression.tokens.literal import LiteralToken @@ -43,7 +46,11 @@ def label(self) -> str: return self.name test_cases = ( - TestCase(name="hold", token=HoldToken(ticks=7), expected=TokenSpan(size=1, ticks=7)), + TestCase( + name="hold", + token=HoldToken(ticks=7), + expected=TokenSpan(size=1, ticks=7), + ), TestCase( name="hold-longest", token=HoldToken(ticks=MAX_HOLD_TICKS), @@ -56,17 +63,32 @@ def label(self) -> str: ), TestCase( name="phrase", - token=PhraseToken(phrase_id=CHEAP_ID, ticks=PHRASE_PLAY_TICKS, transpose=0, default=False), + token=PhraseToken( + phrase_id=CHEAP_ID, + ticks=PHRASE_PLAY_TICKS, + transpose=0, + default=False, + ), expected=TokenSpan(size=2, ticks=PHRASE_PLAY_TICKS), ), TestCase( name="phrase-shifted", - token=PhraseToken(phrase_id=CHEAP_ID, ticks=PHRASE_PLAY_TICKS, transpose=SHIFT, default=False), + token=PhraseToken( + phrase_id=CHEAP_ID, + ticks=PHRASE_PLAY_TICKS, + transpose=SHIFT, + default=False, + ), expected=TokenSpan(size=3, ticks=PHRASE_PLAY_TICKS), ), TestCase( name="phrase-escaped", - token=PhraseToken(phrase_id=PHRASE_ID_ESCAPE, ticks=PHRASE_PLAY_TICKS, transpose=0, default=False), + token=PhraseToken( + phrase_id=PHRASE_ID_ESCAPE, + ticks=PHRASE_PLAY_TICKS, + transpose=0, + default=False, + ), expected=TokenSpan(size=3, ticks=PHRASE_PLAY_TICKS), ), TestCase( @@ -81,7 +103,12 @@ def label(self) -> str: ), TestCase( name="phrase-longest", - token=PhraseToken(phrase_id=CHEAP_ID, ticks=MAX_PHRASE_TICKS, transpose=0, default=False), + token=PhraseToken( + phrase_id=CHEAP_ID, + ticks=MAX_PHRASE_TICKS, + transpose=0, + default=False, + ), expected=TokenSpan(size=2, ticks=MAX_PHRASE_TICKS), ), ) diff --git a/tests/unit/sampletones_player/driver/test_addresses.py b/tests/unit/sampletones_player/driver/test_addresses.py index 91d4b195d..cf364abf1 100644 --- a/tests/unit/sampletones_player/driver/test_addresses.py +++ b/tests/unit/sampletones_player/driver/test_addresses.py @@ -33,5 +33,9 @@ def test_the_song_follows_the_code(self) -> None: def test_a_longer_driver_moves_the_song_alone(self) -> None: shorter = DriverAddresses.for_code(CODE_LENGTH) longer = DriverAddresses.for_code(CODE_LENGTH * 2) - assert (longer.load, longer.init, longer.play) == (shorter.load, shorter.init, shorter.play) + assert (longer.load, longer.init, longer.play) == ( + shorter.load, + shorter.init, + shorter.play, + ) assert longer.song > shorter.song diff --git a/tests/unit/sampletones_player/driver/test_image.py b/tests/unit/sampletones_player/driver/test_image.py index a9151e1b0..13a04fb26 100644 --- a/tests/unit/sampletones_player/driver/test_image.py +++ b/tests/unit/sampletones_player/driver/test_image.py @@ -41,7 +41,10 @@ def test_the_song_begins_where_the_code_ends(self) -> None: def test_the_routines_answer_where_the_specification_states(self) -> None: image = DriverImage.load() - assert (image.addresses.init, image.addresses.play) == (INIT_ADDRESS, PLAY_ADDRESS) + assert (image.addresses.init, image.addresses.play) == ( + INIT_ADDRESS, + PLAY_ADDRESS, + ) def test_the_image_leads_with_a_jump_to_each_routine(self) -> None: assert DriverImage.load().code[: len(JUMP_TABLE) : 3] == bytes((JUMP_ABSOLUTE_OPCODE,) * 2) @@ -62,7 +65,9 @@ def test_an_init_routine_before_the_load_address_is_rejected(self) -> None: with pytest.raises(ValueError, match="the init routine lies at"): DriverImage(**image_fields(init=LOAD_ADDRESS - 1)) - def test_an_image_that_leads_with_anything_but_a_jump_is_rejected(self) -> None: + def test_an_image_that_leads_with_anything_but_a_jump_is_rejected( + self, + ) -> None: with pytest.raises(ValueError, match="rather than the jump"): DriverImage(**image_fields(code=bytes((RETURN_OPCODE,)) * len(CODE))) diff --git a/tests/unit/sampletones_player/export/test_backend.py b/tests/unit/sampletones_player/export/test_backend.py index 532a5e272..bf30a3fc1 100644 --- a/tests/unit/sampletones_player/export/test_backend.py +++ b/tests/unit/sampletones_player/export/test_backend.py @@ -32,7 +32,11 @@ STRING_FIELD_SIZE, TITLE_OFFSET, ) -from sampletones_player.specification.song import LOOP_TICK_OFFSET, NO_LOOP, TOTAL_TICKS_OFFSET +from sampletones_player.specification.song import ( + LOOP_TICK_OFFSET, + NO_LOOP, + TOTAL_TICKS_OFFSET, +) from sampletones_shared.exceptions import OperationCanceled, SongTooLargeError from sampletones_shared.paths.extensions import EXT_FILE_NSF from tests.suite.performance import ( @@ -153,7 +157,10 @@ def test_the_run_writes_the_destination_alone(self, backend: NSFBackend, tmp_pat destination = tmp_path / FILENAME request = player_sample( SAMPLE_NAME, - (lead_slice("lead", SOUNDING_TICKS), bass_slice("bass", SOUNDING_TICKS)), + ( + lead_slice("lead", SOUNDING_TICKS), + bass_slice("bass", SOUNDING_TICKS), + ), nes_frequency=NTSC_FREQUENCY, ) artifact = backend.write_sample(destination, request) @@ -161,13 +168,21 @@ def test_the_run_writes_the_destination_alone(self, backend: NSFBackend, tmp_pat def test_the_program_carries_its_driver(self, backend: NSFBackend, tmp_path: Path) -> None: destination = tmp_path / FILENAME - request = player_sample(SAMPLE_NAME, (lead_slice("lead", SOUNDING_TICKS),), nes_frequency=NTSC_FREQUENCY) + request = player_sample( + SAMPLE_NAME, + (lead_slice("lead", SOUNDING_TICKS),), + nes_frequency=NTSC_FREQUENCY, + ) backend.write_sample(destination, request) assert len(destination.read_bytes()) > HEADER_SIZE def test_the_reconstructions_name_lists_the_program(self, backend: NSFBackend, tmp_path: Path) -> None: destination = tmp_path / FILENAME - request = player_sample(SAMPLE_NAME, (lead_slice("lead", SOUNDING_TICKS),), nes_frequency=NTSC_FREQUENCY) + request = player_sample( + SAMPLE_NAME, + (lead_slice("lead", SOUNDING_TICKS),), + nes_frequency=NTSC_FREQUENCY, + ) backend.write_sample(destination, request) assert read_field(destination.read_bytes(), TITLE_OFFSET) == SAMPLE_NAME @@ -176,18 +191,30 @@ def test_an_export_is_credited_to_nobody(self, backend: NSFBackend, tmp_path: Pa listing the file leaves the line blank. """ destination = tmp_path / FILENAME - request = player_sample(SAMPLE_NAME, (lead_slice("lead", SOUNDING_TICKS),), nes_frequency=NTSC_FREQUENCY) + request = player_sample( + SAMPLE_NAME, + (lead_slice("lead", SOUNDING_TICKS),), + nes_frequency=NTSC_FREQUENCY, + ) backend.write_sample(destination, request) assert read_field(destination.read_bytes(), ARTIST_OFFSET) == "" def test_the_envelopes_cross_over_whole(self, backend: NSFBackend, tmp_path: Path) -> None: destination = tmp_path / FILENAME - request = player_sample(SAMPLE_NAME, (lead_slice("lead", SOUNDING_TICKS),), nes_frequency=NTSC_FREQUENCY) + request = player_sample( + SAMPLE_NAME, + (lead_slice("lead", SOUNDING_TICKS),), + nes_frequency=NTSC_FREQUENCY, + ) assert backend.write_sample(destination, request).truncation is None def test_a_destination_reaches_a_directory_the_run_creates(self, backend: NSFBackend, tmp_path: Path) -> None: destination = tmp_path / "exports" / FILENAME - request = player_sample(SAMPLE_NAME, (lead_slice("lead", SOUNDING_TICKS),), nes_frequency=NTSC_FREQUENCY) + request = player_sample( + SAMPLE_NAME, + (lead_slice("lead", SOUNDING_TICKS),), + nes_frequency=NTSC_FREQUENCY, + ) backend.write_sample(destination, request) assert destination.is_file() @@ -218,7 +245,10 @@ def test_a_slice_plays_the_program_its_reconstruction_would(self, backend: NSFBa together = tmp_path / "together.nsf" backend.write_instrument(alone, instrument) - backend.write_sample(together, player_sample(SAMPLE_NAME, (instrument,), nes_frequency=NTSC_FREQUENCY)) + backend.write_sample( + together, + player_sample(SAMPLE_NAME, (instrument,), nes_frequency=NTSC_FREQUENCY), + ) assert alone.read_bytes() == together.read_bytes() @@ -355,7 +385,11 @@ def test_the_run_names_each_stage_in_the_order_it_reaches_it( tmp_path: Path, ) -> None: reporter: RecordingReporter[ExportProgress] = RecordingReporter() - request = player_sample(SAMPLE_NAME, (lead_slice("lead", SOUNDING_TICKS),), nes_frequency=NTSC_FREQUENCY) + request = player_sample( + SAMPLE_NAME, + (lead_slice("lead", SOUNDING_TICKS),), + nes_frequency=NTSC_FREQUENCY, + ) backend.write_sample(tmp_path / FILENAME, request, reporter) assert reported_stages(reporter.reports) == [ ExportStage.WALKING, @@ -370,7 +404,11 @@ def test_the_compression_reports_the_bytes_it_has_laid_down( ) -> None: """A search ends where the song runs out of phrases that pay, so it counts bytes alone.""" reporter: RecordingReporter[ExportProgress] = RecordingReporter() - request = player_sample(SAMPLE_NAME, (lead_slice("lead", SOUNDING_TICKS),), nes_frequency=NTSC_FREQUENCY) + request = player_sample( + SAMPLE_NAME, + (lead_slice("lead", SOUNDING_TICKS),), + nes_frequency=NTSC_FREQUENCY, + ) backend.write_sample(tmp_path / FILENAME, request, reporter) compressing = [report for report in reporter.reports if report.stage == ExportStage.COMPRESSING] assert compressing and all(report.total is None for report in compressing) @@ -391,7 +429,11 @@ class TestWithdrawingARun: def test_a_withdrawn_run_writes_nothing(self, backend: NSFBackend, tmp_path: Path) -> None: destination = tmp_path / FILENAME reporter: RecordingReporter[ExportProgress] = RecordingReporter(withdraw_at=WITHDRAWN_WHILE_WALKING) - request = player_sample(SAMPLE_NAME, (lead_slice("lead", SOUNDING_TICKS),), nes_frequency=NTSC_FREQUENCY) + request = player_sample( + SAMPLE_NAME, + (lead_slice("lead", SOUNDING_TICKS),), + nes_frequency=NTSC_FREQUENCY, + ) with pytest.raises(OperationCanceled): backend.write_sample(destination, request, reporter) @@ -404,7 +446,11 @@ def test_a_run_withdrawn_mid_compression_writes_nothing( ) -> None: destination = tmp_path / FILENAME reporter: RecordingReporter[ExportProgress] = RecordingReporter(withdraw_at=WITHDRAWN_WHILE_COMPRESSING) - request = player_sample(SAMPLE_NAME, (lead_slice("lead", SOUNDING_TICKS),), nes_frequency=NTSC_FREQUENCY) + request = player_sample( + SAMPLE_NAME, + (lead_slice("lead", SOUNDING_TICKS),), + nes_frequency=NTSC_FREQUENCY, + ) with pytest.raises(OperationCanceled): backend.write_sample(destination, request, reporter) @@ -434,7 +480,10 @@ def repeated_sample() -> SampleExport: """A reconstruction holding one note, which every scheme past the first writes as a run.""" return player_sample( SAMPLE_NAME, - (lead_slice("lead", REPEATED_TICKS), bass_slice("bass", REPEATED_TICKS)), + ( + lead_slice("lead", REPEATED_TICKS), + bass_slice("bass", REPEATED_TICKS), + ), nes_frequency=NTSC_FREQUENCY, ) @@ -459,27 +508,42 @@ def test_the_file_is_listed_under_the_chosen_text( expected: str, ) -> None: destination = tmp_path / FILENAME - program = chosen_program(channels=ALL_CHANNELS, loop_tick=SONG_START, scheme=CompressionScheme.SEARCH) + program = chosen_program( + channels=ALL_CHANNELS, + loop_tick=SONG_START, + scheme=CompressionScheme.SEARCH, + ) backend.choosing(program).write_project(destination, ProjectExport(project=drum_project())) assert read_field(destination.read_bytes(), offset) == expected def test_a_song_chosen_to_play_once_stops_at_its_end(self, backend: NSFBackend, tmp_path: Path) -> None: destination = tmp_path / FILENAME - program = chosen_program(channels=ALL_CHANNELS, loop_tick=None, scheme=CompressionScheme.SEARCH) + program = chosen_program( + channels=ALL_CHANNELS, + loop_tick=None, + scheme=CompressionScheme.SEARCH, + ) backend.choosing(program).write_project(destination, ProjectExport(project=drum_project())) assert written_loop_tick(destination.read_bytes()) == NO_LOOP def test_a_reconstruction_repeats_where_the_program_says(self, backend: NSFBackend, tmp_path: Path) -> None: """The slices play once, and the program asks for them to come round all the same.""" destination = tmp_path / FILENAME - program = chosen_program(channels=ALL_CHANNELS, loop_tick=SONG_START, scheme=CompressionScheme.SEARCH) + program = chosen_program( + channels=ALL_CHANNELS, + loop_tick=SONG_START, + scheme=CompressionScheme.SEARCH, + ) backend.choosing(program).write_sample(destination, repeated_sample()) assert written_loop_tick(destination.read_bytes()) == SONG_START def test_a_lighter_scheme_writes_a_larger_file(self, backend: NSFBackend, tmp_path: Path) -> None: spelled_out = tmp_path / "none.nsf" runs = tmp_path / "runs.nsf" - for destination, scheme in ((spelled_out, CompressionScheme.NONE), (runs, CompressionScheme.RUNS)): + for destination, scheme in ( + (spelled_out, CompressionScheme.NONE), + (runs, CompressionScheme.RUNS), + ): program = chosen_program(channels=ALL_CHANNELS, loop_tick=None, scheme=scheme) backend.choosing(program).write_sample(destination, repeated_sample()) @@ -491,6 +555,12 @@ def test_choosing_a_program_leaves_the_backend_it_came_from_as_it_was( tmp_path: Path, ) -> None: destination = tmp_path / FILENAME - backend.choosing(chosen_program(channels=ALL_CHANNELS, loop_tick=None, scheme=CompressionScheme.NONE)) + backend.choosing( + chosen_program( + channels=ALL_CHANNELS, + loop_tick=None, + scheme=CompressionScheme.NONE, + ) + ) backend.write_project(destination, ProjectExport(project=drum_project())) assert read_field(destination.read_bytes(), TITLE_OFFSET) == PROJECT_TITLE diff --git a/tests/unit/sampletones_player/export/test_program.py b/tests/unit/sampletones_player/export/test_program.py index c40dc3bc2..3eb0cd7cf 100644 --- a/tests/unit/sampletones_player/export/test_program.py +++ b/tests/unit/sampletones_player/export/test_program.py @@ -8,7 +8,11 @@ from sampletones_core.project.project import Project from sampletones_player.builder import SONG_START, loop_tick_from_instruments from sampletones_player.compression.scheme import CompressionScheme -from sampletones_player.export.program import DEFAULT_SCHEME, NO_ARTIST, NSFProgram +from sampletones_player.export.program import ( + DEFAULT_SCHEME, + NO_ARTIST, + NSFProgram, +) from sampletones_player.nsf.information import NSFInformation from sampletones_shared.application import SAMPLETONES_COPYRIGHT from tests.suite.player import ( @@ -67,9 +71,14 @@ def test_it_is_compressed_under_the_default_scheme(self, project: Project) -> No class TestTheProgramASampleStates: """What a reconstruction's slices are written as when nobody chose otherwise.""" - def test_it_is_listed_under_the_reconstructions_name_credited_to_nobody(self) -> None: + def test_it_is_listed_under_the_reconstructions_name_credited_to_nobody( + self, + ) -> None: information = NSFProgram.for_sample(sample(loop=False)).information - assert (information.title, information.artist) == (SAMPLE_NAME, NO_ARTIST) + assert (information.title, information.artist) == ( + SAMPLE_NAME, + NO_ARTIST, + ) def test_it_sounds_every_channel(self) -> None: assert NSFProgram.for_sample(sample(loop=False)).channels == ALL_CHANNELS diff --git a/tests/unit/sampletones_player/nsf/test_header.py b/tests/unit/sampletones_player/nsf/test_header.py index 2a4ec7097..cb4139495 100644 --- a/tests/unit/sampletones_player/nsf/test_header.py +++ b/tests/unit/sampletones_player/nsf/test_header.py @@ -150,14 +150,19 @@ def test_the_field_carries_its_text(self, test_case: TestCase) -> None: field = read_string(header(), test_case.offset) assert field == test_case.expected.encode("utf-8").ljust(STRING_FIELD_SIZE, b"\x00") - def test_text_longer_than_the_field_is_written_as_much_as_fits_before_its_terminator(self) -> None: + def test_text_longer_than_the_field_is_written_as_much_as_fits_before_its_terminator( + self, + ) -> None: """A player reads each field up to its NUL, so the field keeps a byte for one.""" overlong = "A" * (STRING_FIELD_SIZE * 2) data = header_to_bytes(NSFInformation(title=overlong, artist=ARTIST), ADDRESSES) assert read_string(data, TITLE_OFFSET) == overlong.encode("utf-8")[:STRING_TEXT_SIZE] + b"\x00" def test_the_fields_stand_back_to_back(self) -> None: - assert (ARTIST_OFFSET - TITLE_OFFSET, COPYRIGHT_OFFSET - ARTIST_OFFSET) == ( + assert ( + ARTIST_OFFSET - TITLE_OFFSET, + COPYRIGHT_OFFSET - ARTIST_OFFSET, + ) == ( STRING_FIELD_SIZE, STRING_FIELD_SIZE, ) @@ -169,7 +174,9 @@ class TestHeaderPlayback: def test_the_ntsc_period_is_the_one_the_specification_names(self) -> None: assert read_word(header(), NTSC_PERIOD_OFFSET) == NTSC_PLAY_PERIOD_MICROSECONDS - def test_the_ntsc_period_asks_for_the_rate_the_schedule_counts_in(self) -> None: + def test_the_ntsc_period_asks_for_the_rate_the_schedule_counts_in( + self, + ) -> None: """A player reading the field and one driving from the frame run a stream at one speed.""" requested = Fraction(MICROSECONDS_PER_SECOND, read_word(header(), NTSC_PERIOD_OFFSET)) assert abs(requested - NTSC_FRAME_RATE) / NTSC_FRAME_RATE < RATE_TOLERANCE diff --git a/tests/unit/sampletones_player/nsf/test_information.py b/tests/unit/sampletones_player/nsf/test_information.py index a8a9cfed7..8d482da4f 100644 --- a/tests/unit/sampletones_player/nsf/test_information.py +++ b/tests/unit/sampletones_player/nsf/test_information.py @@ -3,8 +3,16 @@ import pytest -from sampletones_player.nsf.information import TEXT_ENCODING, NSFInformation, field_size, fit_field -from sampletones_player.specification.nsf import STRING_FIELD_SIZE, STRING_TEXT_SIZE +from sampletones_player.nsf.information import ( + TEXT_ENCODING, + NSFInformation, + field_size, + fit_field, +) +from sampletones_player.specification.nsf import ( + STRING_FIELD_SIZE, + STRING_TEXT_SIZE, +) from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase @@ -23,8 +31,16 @@ class TestCase(BaseRegularTestCase): test_cases = ( TestCase(label="short text", text=SHORT_TEXT, expected=SHORT_TEXT), TestCase(label="empty text", text="", expected=""), - TestCase(label="exactly the room", text="A" * STRING_TEXT_SIZE, expected="A" * STRING_TEXT_SIZE), - TestCase(label="one byte over", text="A" * STRING_FIELD_SIZE, expected="A" * STRING_TEXT_SIZE), + TestCase( + label="exactly the room", + text="A" * STRING_TEXT_SIZE, + expected="A" * STRING_TEXT_SIZE, + ), + TestCase( + label="one byte over", + text="A" * STRING_FIELD_SIZE, + expected="A" * STRING_TEXT_SIZE, + ), TestCase( label="a character straddling the edge", text="A" * (STRING_TEXT_SIZE - 1) + WIDE_CHARACTER, @@ -55,4 +71,8 @@ class TestNSFInformation: def test_every_field_is_held_as_it_fits(self) -> None: overlong = "B" * STRING_FIELD_SIZE information = NSFInformation(title=overlong, artist=overlong, copyright=overlong) - assert {information.title, information.artist, information.copyright} == {fit_field(overlong)} + assert { + information.title, + information.artist, + information.copyright, + } == {fit_field(overlong)} diff --git a/tests/unit/sampletones_player/nsf/test_layout.py b/tests/unit/sampletones_player/nsf/test_layout.py index 97002e18f..fe6dbc1db 100644 --- a/tests/unit/sampletones_player/nsf/test_layout.py +++ b/tests/unit/sampletones_player/nsf/test_layout.py @@ -10,7 +10,10 @@ PHRASE_TABLE_ENTRY_SIZE, ) from sampletones_player.specification.planes import PLANE_COUNT -from sampletones_player.specification.song import ABSENT_STREAM, SONG_HEADER_SIZE +from sampletones_player.specification.song import ( + ABSENT_STREAM, + SONG_HEADER_SIZE, +) from tests.suite.player import ( PLAYER_FULL_VOLUME, PLAYER_OCTAVE_UP_TIMER, @@ -33,7 +36,11 @@ def figure_song(loop_tick: int) -> Song: - return player_song(resting_streams(FIGURE * FIGURE_REPEATS), NTSC_FREQUENCY, loop_tick=loop_tick) + return player_song( + resting_streams(FIGURE * FIGURE_REPEATS), + NTSC_FREQUENCY, + loop_tick=loop_tick, + ) def present(song: Song, layout: SongLayout) -> List[Tuple[bytes, int]]: @@ -82,7 +89,9 @@ def test_the_block_ends_behind_the_last_stream(self) -> None: stream, offset = present(song, layout)[-1] assert layout.size == offset + len(stream) - def test_an_absent_plane_states_the_sentinel_for_its_stream_and_its_entry(self) -> None: + def test_an_absent_plane_states_the_sentinel_for_its_stream_and_its_entry( + self, + ) -> None: song = figure_song(LOOP_TICK) layout = SongLayout.of(song) absent = [plane for plane, stream in enumerate(song.planes.streams) if not stream] @@ -105,8 +114,14 @@ def test_an_entry_stands_at_the_token_the_loop_tick_starts(self) -> None: ABSENT_STREAM if entry is None else offset + entry for offset, entry in zip(layout.streams, entered) ) - def test_a_song_that_stops_re_enters_at_each_streams_own_start(self) -> None: - song = player_song(resting_streams(FIGURE * FIGURE_REPEATS), NTSC_FREQUENCY, loop_tick=None) + def test_a_song_that_stops_re_enters_at_each_streams_own_start( + self, + ) -> None: + song = player_song( + resting_streams(FIGURE * FIGURE_REPEATS), + NTSC_FREQUENCY, + loop_tick=None, + ) layout = SongLayout.of(song) assert layout.loop_entries == layout.streams diff --git a/tests/unit/sampletones_player/nsf/test_song.py b/tests/unit/sampletones_player/nsf/test_song.py index b7eddfa5f..ebabedc21 100644 --- a/tests/unit/sampletones_player/nsf/test_song.py +++ b/tests/unit/sampletones_player/nsf/test_song.py @@ -10,7 +10,10 @@ from sampletones_player.nsf.song import STATED_BEYOND_THE_FIRST, song_to_bytes from sampletones_player.song import Song from sampletones_player.specification.binary import WORD_SIZE -from sampletones_player.specification.compression import PHRASE_DEFAULT_SIZE, PHRASE_LENGTH_SIZE +from sampletones_player.specification.compression import ( + PHRASE_DEFAULT_SIZE, + PHRASE_LENGTH_SIZE, +) from sampletones_player.specification.planes import PLANE_COUNT from sampletones_player.specification.song import ( ABSENT_STREAM, @@ -120,7 +123,9 @@ def test_the_song_serializes_to_the_expected_bytes(self) -> None: expected = self.EXPECTED_HEADER + song.pitches.data + self.EXPECTED_STREAMS assert song_to_bytes(song, PROGRAM_AREA_BYTES) == expected - def test_the_header_runs_to_the_length_the_offsets_are_read_at(self) -> None: + def test_the_header_runs_to_the_length_the_offsets_are_read_at( + self, + ) -> None: assert len(self.EXPECTED_HEADER) == SONG_HEADER_SIZE @@ -154,7 +159,11 @@ def test_the_step_reaches_the_header_as_the_driver_holds_it(self, test_case: Tes assert read_word(data, STEP_FRACTION_OFFSET) == step.fraction def test_the_header_states_the_songs_length(self) -> None: - song = player_song(resting_streams((SOUNDING, OCTAVE_UP, RESTING)), NTSC_FREQUENCY, loop_tick=None) + song = player_song( + resting_streams((SOUNDING, OCTAVE_UP, RESTING)), + NTSC_FREQUENCY, + loop_tick=None, + ) data = song_to_bytes(song, PROGRAM_AREA_BYTES) assert read_word(data, TOTAL_TICKS_OFFSET) == song.ticks @@ -249,7 +258,9 @@ def test_the_streams_fill_the_song_to_its_last_byte(self) -> None: class TestLoopEntries: """Where each plane's stream is re-entered once the song repeats.""" - def test_a_song_that_repeats_states_the_token_its_loop_tick_starts(self) -> None: + def test_a_song_that_repeats_states_the_token_its_loop_tick_starts( + self, + ) -> None: song = repeating_song() data = song_to_bytes(song, PROGRAM_AREA_BYTES) entered = song.planes.loop_entries @@ -278,12 +289,16 @@ def test_a_song_past_the_available_space_raises(self) -> None: with pytest.raises(SongTooLargeError): song_to_bytes(song, len(data) - 1) - def test_a_song_filling_the_available_space_exactly_is_written(self) -> None: + def test_a_song_filling_the_available_space_exactly_is_written( + self, + ) -> None: song = two_tick_song(NTSC_FREQUENCY) data = song_to_bytes(song, PROGRAM_AREA_BYTES) assert song_to_bytes(song, len(data)) == data - def test_a_song_reaching_past_the_offset_field_names_what_overflowed(self) -> None: + def test_a_song_reaching_past_the_offset_field_names_what_overflowed( + self, + ) -> None: """A block given exactly the room it takes is still refused where an offset overflows.""" song = spelled_song(MAX_BLOCK_OFFSET, NTSC_FREQUENCY) with pytest.raises(SongTooLargeError) as overflow: diff --git a/tests/unit/sampletones_player/registers/test_channel.py b/tests/unit/sampletones_player/registers/test_channel.py index 7e3222e02..7d9518e35 100644 --- a/tests/unit/sampletones_player/registers/test_channel.py +++ b/tests/unit/sampletones_player/registers/test_channel.py @@ -9,7 +9,10 @@ PulseInstruction, TriangleInstruction, ) -from sampletones_player.registers.channel import channel_instructions, channel_registers +from sampletones_player.registers.channel import ( + channel_instructions, + channel_registers, +) from sampletones_player.specification.registers import ( TRIANGLE_COUNTER_CONTROL, TRIANGLE_SOUNDING_RELOAD, @@ -59,15 +62,25 @@ def test_a_stream_of_another_channels_type_raises(self) -> None: class TestChannelRegisters: """Naming the channel is the whole of what it takes to encode a stream.""" - def test_a_sounding_channel_carries_a_tick_per_instruction_and_a_release(self) -> None: - registers = channel_registers(ChannelName.PULSE1, {ChannelName.PULSE1: melody()}, PLAYER_TIMER_TABLE) + def test_a_sounding_channel_carries_a_tick_per_instruction_and_a_release( + self, + ) -> None: + registers = channel_registers( + ChannelName.PULSE1, + {ChannelName.PULSE1: melody()}, + PLAYER_TIMER_TABLE, + ) assert len(registers) == SOUNDING_TICKS + 1 def test_a_channel_the_song_leaves_out_rests_for_a_tick(self) -> None: assert len(channel_registers(ChannelName.PULSE2, {}, PLAYER_TIMER_TABLE)) == 1 def test_a_pitch_reaches_the_timer_the_table_states(self) -> None: - registers = channel_registers(ChannelName.PULSE1, {ChannelName.PULSE1: melody()}, PLAYER_TIMER_TABLE) + registers = channel_registers( + ChannelName.PULSE1, + {ChannelName.PULSE1: melody()}, + PLAYER_TIMER_TABLE, + ) timer = PLAYER_TIMER_TABLE[PLAYER_REFERENCE_PITCH] assert registers[0].values[1:] == (timer & 0xFF, timer >> 8) @@ -91,6 +104,12 @@ def test_the_noise_channel_answers_in_its_own_registers(self) -> None: ) assert registers[0].control & 0x0F == NOISE_VOLUME - def test_a_channel_holding_another_channels_instructions_raises(self) -> None: + def test_a_channel_holding_another_channels_instructions_raises( + self, + ) -> None: with pytest.raises(TypeError): - channel_registers(ChannelName.TRIANGLE, {ChannelName.TRIANGLE: melody()}, PLAYER_TIMER_TABLE) + channel_registers( + ChannelName.TRIANGLE, + {ChannelName.TRIANGLE: melody()}, + PLAYER_TIMER_TABLE, + ) diff --git a/tests/unit/sampletones_player/registers/test_dividers.py b/tests/unit/sampletones_player/registers/test_dividers.py index aa6f31d8b..65b15fbf9 100644 --- a/tests/unit/sampletones_player/registers/test_dividers.py +++ b/tests/unit/sampletones_player/registers/test_dividers.py @@ -1,8 +1,16 @@ import pytest -from sampletones_core.constants.general import MAX_PITCH, MAX_TIMER, MIN_PITCH, MIN_TIMER +from sampletones_core.constants.general import ( + MAX_PITCH, + MAX_TIMER, + MIN_PITCH, + MIN_TIMER, +) from sampletones_core.timers.nearest import nearest_pitch -from sampletones_player.registers.dividers import anchored_pitches, bent_dividers +from sampletones_player.registers.dividers import ( + anchored_pitches, + bent_dividers, +) from sampletones_player.specification.binary import SIGNED_BYTE_LIMIT from tests.suite.player import PLAYER_REFERENCE_PITCH, PLAYER_TIMER_TABLE @@ -17,14 +25,20 @@ def test_an_unbent_frame_sounds_its_note_s_own_divider(self) -> None: def test_every_frame_moves_by_its_own_bend(self) -> None: offsets = (3, -3, 40) - dividers = bent_dividers((PLAYER_REFERENCE_PITCH,) * len(offsets), offsets, PLAYER_TIMER_TABLE) + dividers = bent_dividers( + (PLAYER_REFERENCE_PITCH,) * len(offsets), + offsets, + PLAYER_TIMER_TABLE, + ) assert dividers == tuple(PLAYER_TIMER_TABLE[PLAYER_REFERENCE_PITCH] + offset for offset in offsets) def test_a_bend_past_the_register_stops_at_its_edge(self) -> None: dividers = bent_dividers((MIN_PITCH, MAX_PITCH), (MAX_TIMER, -MAX_TIMER), PLAYER_TIMER_TABLE) assert dividers == (MAX_TIMER, MIN_TIMER) - def test_notes_and_bends_covering_different_frames_are_refused(self) -> None: + def test_notes_and_bends_covering_different_frames_are_refused( + self, + ) -> None: with pytest.raises(ValueError): bent_dividers((PLAYER_REFERENCE_PITCH,), (0, 0), PLAYER_TIMER_TABLE) @@ -32,17 +46,23 @@ def test_notes_and_bends_covering_different_frames_are_refused(self) -> None: class TestAnchoredPitches: """A divider is counted from the frame's own note wherever the bend fits the plane's byte.""" - def test_a_bend_past_halfway_is_counted_from_the_frames_own_note(self) -> None: + def test_a_bend_past_halfway_is_counted_from_the_frames_own_note( + self, + ) -> None: divider = PLAYER_TIMER_TABLE[PLAYER_REFERENCE_PITCH] - SIGNED_BYTE_LIMIT + 1 assert nearest_pitch(PLAYER_TIMER_TABLE, divider).pitch != PLAYER_REFERENCE_PITCH assert anchored_pitches((PLAYER_REFERENCE_PITCH,), (divider,), PLAYER_TIMER_TABLE) == (PLAYER_REFERENCE_PITCH,) - def test_a_bend_past_the_byte_is_counted_from_the_nearest_pitch(self) -> None: + def test_a_bend_past_the_byte_is_counted_from_the_nearest_pitch( + self, + ) -> None: divider = PLAYER_TIMER_TABLE[PLAYER_REFERENCE_PITCH] - SIGNED_BYTE_LIMIT - 1 assert anchored_pitches((PLAYER_REFERENCE_PITCH,), (divider,), PLAYER_TIMER_TABLE) == ( nearest_pitch(PLAYER_TIMER_TABLE, divider).pitch, ) - def test_notes_and_dividers_covering_different_frames_are_refused(self) -> None: + def test_notes_and_dividers_covering_different_frames_are_refused( + self, + ) -> None: with pytest.raises(ValueError): anchored_pitches((PLAYER_REFERENCE_PITCH,), (), PLAYER_TIMER_TABLE) diff --git a/tests/unit/sampletones_player/registers/test_pulse.py b/tests/unit/sampletones_player/registers/test_pulse.py index 07550a990..4152a5aa9 100644 --- a/tests/unit/sampletones_player/registers/test_pulse.py +++ b/tests/unit/sampletones_player/registers/test_pulse.py @@ -18,7 +18,10 @@ from sampletones_core.instructions import PulseInstruction from sampletones_core.timers.nearest import nearest_pitch from sampletones_player.registers.pulse import PulseRegisters -from sampletones_player.specification.registers import MAX_REGISTER_VALUE, TIMER_HIGH_SHIFT +from sampletones_player.specification.registers import ( + MAX_REGISTER_VALUE, + TIMER_HIGH_SHIFT, +) from tests.suite.base import BaseTestSuite from tests.suite.case import BaseAutolabelTestCase from tests.suite.player import ( @@ -66,7 +69,11 @@ def test_a_tick_states_control_then_timer(self) -> None: instructions = [sounding_pulse(PLAYER_REFERENCE_PITCH, MAX_VOLUME, MAX_DUTY_CYCLE)] registers = PulseRegisters.from_instructions(instructions, PLAYER_TIMER_TABLE)[0] timer = PLAYER_TIMER_TABLE[PLAYER_REFERENCE_PITCH] - assert registers.values == (0xFF, timer & MAX_REGISTER_VALUE, timer >> TIMER_HIGH_SHIFT) + assert registers.values == ( + 0xFF, + timer & MAX_REGISTER_VALUE, + timer >> TIMER_HIGH_SHIFT, + ) class TestPulseControlByte(BaseTestSuite): @@ -132,7 +139,10 @@ def test_rest_keeps_the_duty_cycle(self) -> None: def test_rest_keeps_the_timer(self) -> None: sounding, resting = self.encode_note_then_rest()[:2] - assert (resting.timer_low, resting.timer_high) == (sounding.timer_low, sounding.timer_high) + assert (resting.timer_low, resting.timer_high) == ( + sounding.timer_low, + sounding.timer_high, + ) class TestPulseReleaseTick: @@ -145,7 +155,10 @@ def test_pulse_gains_a_silent_closing_tick(self) -> None: assert registers[-1].control & 0x0F == 0 def test_a_sample_ending_in_a_rest_gains_no_extra_tick(self) -> None: - instructions = [sounding_pulse(PLAYER_REFERENCE_PITCH, MAX_VOLUME, 0), silent_pulse()] + instructions = [ + sounding_pulse(PLAYER_REFERENCE_PITCH, MAX_VOLUME, 0), + silent_pulse(), + ] assert len(PulseRegisters.from_instructions(instructions, PLAYER_TIMER_TABLE)) == 2 def test_no_instructions_encode_to_no_ticks(self) -> None: @@ -192,8 +205,13 @@ def test_the_timer_is_the_divider_the_generator_sounds( expected >> TIMER_HIGH_SHIFT, ) - def test_a_bend_within_a_byte_is_counted_from_the_frames_own_note(self) -> None: - instructions = [bent_pulse(PLAYER_REFERENCE_PITCH, -40, 0), bent_pulse(PLAYER_REFERENCE_PITCH, 0, 7)] + def test_a_bend_within_a_byte_is_counted_from_the_frames_own_note( + self, + ) -> None: + instructions = [ + bent_pulse(PLAYER_REFERENCE_PITCH, -40, 0), + bent_pulse(PLAYER_REFERENCE_PITCH, 0, 7), + ] registers = PulseRegisters.from_instructions(instructions, PLAYER_TIMER_TABLE) assert [tick.anchor for tick in registers] == [PLAYER_REFERENCE_PITCH] * len(registers) @@ -204,11 +222,23 @@ def test_a_bend_past_a_byte_is_counted_from_the_nearest_pitch(self) -> None: assert tick.anchor != PLAYER_REFERENCE_PITCH def test_a_rest_holds_the_bent_divider(self) -> None: - instructions = [bent_pulse(PLAYER_REFERENCE_PITCH, 9, 1), silent_pulse()] + instructions = [ + bent_pulse(PLAYER_REFERENCE_PITCH, 9, 1), + silent_pulse(), + ] sounding, resting = PulseRegisters.from_instructions(instructions, PLAYER_TIMER_TABLE)[:2] - assert (resting.timer_low, resting.timer_high) == (sounding.timer_low, sounding.timer_high) + assert (resting.timer_low, resting.timer_high) == ( + sounding.timer_low, + sounding.timer_high, + ) def test_a_rest_before_the_first_note_takes_its_bent_divider(self) -> None: - instructions = [silent_pulse(), bent_pulse(PLAYER_REFERENCE_PITCH, -9, 0)] + instructions = [ + silent_pulse(), + bent_pulse(PLAYER_REFERENCE_PITCH, -9, 0), + ] resting, sounding = PulseRegisters.from_instructions(instructions, PLAYER_TIMER_TABLE)[:2] - assert (resting.timer_low, resting.timer_high) == (sounding.timer_low, sounding.timer_high) + assert (resting.timer_low, resting.timer_high) == ( + sounding.timer_low, + sounding.timer_high, + ) diff --git a/tests/unit/sampletones_player/registers/test_streams.py b/tests/unit/sampletones_player/registers/test_streams.py index 69a1cb7de..7e0256afb 100644 --- a/tests/unit/sampletones_player/registers/test_streams.py +++ b/tests/unit/sampletones_player/registers/test_streams.py @@ -31,7 +31,11 @@ def test_the_longest_channel_states_the_length(self) -> None: def test_a_channel_past_its_end_holds_its_final_values(self) -> None: streams = resting_streams((SOUNDING, RESTING)) _, pulse2, triangle, noise = streams.at(1) - assert (pulse2, triangle, noise) == (streams.pulse2[0], streams.triangle[0], streams.noise[0]) + assert (pulse2, triangle, noise) == ( + streams.pulse2[0], + streams.triangle[0], + streams.noise[0], + ) def test_every_channel_reads_its_own_tick_while_it_lasts(self) -> None: streams = resting_streams((SOUNDING, OCTAVE_UP)) @@ -46,13 +50,20 @@ def test_padding_leaves_a_full_length_channel_as_it_stands(self) -> None: streams = resting_streams((SOUNDING, OCTAVE_UP, RESTING)) assert streams.padded[0] == (SOUNDING, OCTAVE_UP, RESTING) - def test_padding_repeats_the_final_values_of_a_shorter_channel(self) -> None: + def test_padding_repeats_the_final_values_of_a_shorter_channel( + self, + ) -> None: streams = resting_streams((SOUNDING, OCTAVE_UP, RESTING)) assert streams.padded[3] == (streams.noise[0],) * 3 def test_the_streams_stand_in_channel_order(self) -> None: streams = resting_streams((SOUNDING,)) - assert streams.ordered == (streams.pulse1, streams.pulse2, streams.triangle, streams.noise) + assert streams.ordered == ( + streams.pulse1, + streams.pulse2, + streams.triangle, + streams.noise, + ) def test_a_channel_without_a_tick_raises(self) -> None: with pytest.raises(ValidationError): diff --git a/tests/unit/sampletones_player/registers/test_triangle.py b/tests/unit/sampletones_player/registers/test_triangle.py index 55e592af9..986284a29 100644 --- a/tests/unit/sampletones_player/registers/test_triangle.py +++ b/tests/unit/sampletones_player/registers/test_triangle.py @@ -3,12 +3,21 @@ from sampletones_core.configs import Config from sampletones_core.constants.enums import ChannelName from sampletones_core.constants.general import MAX_VOLUME -from sampletones_core.generators.implementation.triangle import TriangleGenerator +from sampletones_core.generators.implementation.triangle import ( + TriangleGenerator, +) from sampletones_core.instructions import TriangleInstruction from sampletones_player.registers.pulse import PulseRegisters from sampletones_player.registers.triangle import TriangleRegisters -from sampletones_player.specification.registers import MAX_REGISTER_VALUE, TIMER_HIGH_SHIFT -from tests.suite.player import PLAYER_REFERENCE_PITCH, PLAYER_TIMER_TABLE, sounding_pulse +from sampletones_player.specification.registers import ( + MAX_REGISTER_VALUE, + TIMER_HIGH_SHIFT, +) +from tests.suite.player import ( + PLAYER_REFERENCE_PITCH, + PLAYER_TIMER_TABLE, + sounding_pulse, +) def sounding_triangle() -> TriangleInstruction: @@ -21,7 +30,11 @@ class TestTriangleTickRecord: def test_a_tick_states_the_counter_then_timer(self) -> None: registers = TriangleRegisters.from_instructions([sounding_triangle()], PLAYER_TIMER_TABLE)[0] timer = PLAYER_TIMER_TABLE[PLAYER_REFERENCE_PITCH] - assert registers.values == (0xFF, timer & MAX_REGISTER_VALUE, timer >> TIMER_HIGH_SHIFT) + assert registers.values == ( + 0xFF, + timer & MAX_REGISTER_VALUE, + timer >> TIMER_HIGH_SHIFT, + ) class TestTriangleRegisters: @@ -36,14 +49,23 @@ def test_sounding_tick_reloads_the_counter_fully(self) -> None: assert registers[0].linear_counter == 0xFF def test_resting_tick_reloads_the_counter_to_zero(self) -> None: - instructions = [sounding_triangle(), TriangleInstruction.null_instruction()] + instructions = [ + sounding_triangle(), + TriangleInstruction.null_instruction(), + ] registers = TriangleRegisters.from_instructions(instructions, PLAYER_TIMER_TABLE) assert registers[1].linear_counter == 0x80 def test_rest_keeps_the_timer(self) -> None: - instructions = [sounding_triangle(), TriangleInstruction.null_instruction()] + instructions = [ + sounding_triangle(), + TriangleInstruction.null_instruction(), + ] sounding, resting = TriangleRegisters.from_instructions(instructions, PLAYER_TIMER_TABLE)[:2] - assert (resting.timer_low, resting.timer_high) == (sounding.timer_low, sounding.timer_high) + assert (resting.timer_low, resting.timer_high) == ( + sounding.timer_low, + sounding.timer_high, + ) def test_triangle_shares_the_pulse_timer(self) -> None: triangle = TriangleRegisters.from_instructions([sounding_triangle()], PLAYER_TIMER_TABLE) @@ -51,7 +73,10 @@ def test_triangle_shares_the_pulse_timer(self) -> None: [sounding_pulse(PLAYER_REFERENCE_PITCH, MAX_VOLUME, 0)], PLAYER_TIMER_TABLE, ) - assert (triangle[0].timer_low, triangle[0].timer_high) == (pulse[0].timer_low, pulse[0].timer_high) + assert (triangle[0].timer_low, triangle[0].timer_high) == ( + pulse[0].timer_low, + pulse[0].timer_high, + ) class TestTriangleBend: @@ -85,4 +110,7 @@ def test_a_rest_holds_the_bent_divider(self) -> None: TriangleInstruction.null_instruction(), ] sounding, resting = TriangleRegisters.from_instructions(instructions, PLAYER_TIMER_TABLE)[:2] - assert (resting.timer_low, resting.timer_high) == (sounding.timer_low, sounding.timer_high) + assert (resting.timer_low, resting.timer_high) == ( + sounding.timer_low, + sounding.timer_high, + ) diff --git a/tests/unit/sampletones_player/specification/test_planes.py b/tests/unit/sampletones_player/specification/test_planes.py index 3c30e0e9f..d9fec442b 100644 --- a/tests/unit/sampletones_player/specification/test_planes.py +++ b/tests/unit/sampletones_player/specification/test_planes.py @@ -2,7 +2,11 @@ import pytest -from sampletones_core.constants.enums import ALL_CHANNELS, TONE_CHANNELS, ChannelName +from sampletones_core.constants.enums import ( + ALL_CHANNELS, + TONE_CHANNELS, + ChannelName, +) from sampletones_player.compression.pitch import PITCH_COUNT from sampletones_player.compression.planes.order import PlaneOrder from sampletones_player.specification.planes import ( @@ -27,12 +31,16 @@ def test_every_channel_names_what_it_sounds(self) -> None: assert PlaneRole.VALUE in roles - def test_a_control_belongs_to_each_channel_whose_timbre_turns_over(self) -> None: + def test_a_control_belongs_to_each_channel_whose_timbre_turns_over( + self, + ) -> None: timbred = {plane.channel for plane in PLANES if plane.role is PlaneRole.CONTROL} assert timbred == ALL_CHANNELS - {ChannelName.TRIANGLE} - def test_the_triangle_names_its_silence_in_the_pitch_it_stands_at(self) -> None: + def test_the_triangle_names_its_silence_in_the_pitch_it_stands_at( + self, + ) -> None: triangle = PLANES[plane_index(ChannelName.TRIANGLE, PlaneRole.VALUE)] assert triangle.seeded == SILENT_PITCH_INDEX @@ -43,7 +51,9 @@ def test_a_bend_belongs_to_each_channel_whose_divider_moves(self) -> None: assert bending == TONE_CHANNELS - def test_a_plane_is_named_by_the_channel_and_the_part_it_carries(self) -> None: + def test_a_plane_is_named_by_the_channel_and_the_part_it_carries( + self, + ) -> None: assert PLANES[plane_index(ChannelName.NOISE, PlaneRole.VALUE)].name == "noise_value" def test_the_table_names_each_plane_once(self) -> None: diff --git a/tests/unit/sampletones_player/test_builder.py b/tests/unit/sampletones_player/test_builder.py index a45e4a72e..beebcfa6f 100644 --- a/tests/unit/sampletones_player/test_builder.py +++ b/tests/unit/sampletones_player/test_builder.py @@ -62,7 +62,9 @@ def melody() -> List[InstructionUnion]: return [sounding_pulse(PLAYER_REFERENCE_PITCH, PLAYER_FULL_VOLUME, 0) for _ in range(SOUNDING_TICKS)] -def one_channel(generator: ChannelName) -> Dict[ChannelName, List[InstructionUnion]]: +def one_channel( + generator: ChannelName, +) -> Dict[ChannelName, List[InstructionUnion]]: return {generator: melody()} @@ -89,25 +91,39 @@ def bass(*, loop: bool) -> InstrumentExport: class TestStreamsFromInstructions: """The four channels encoded together, each through the encoder its own type names.""" - def test_a_sounding_channel_carries_a_tick_per_instruction_and_a_release(self) -> None: + def test_a_sounding_channel_carries_a_tick_per_instruction_and_a_release( + self, + ) -> None: streams = streams_from_instructions(one_channel(ChannelName.PULSE1), PLAYER_TIMER_TABLE) assert len(streams.pulse1) == SOUNDING_TICKS + 1 def test_a_channel_describing_no_frame_carries_a_single_tick(self) -> None: streams = streams_from_instructions(one_channel(ChannelName.PULSE1), PLAYER_TIMER_TABLE) - assert (len(streams.pulse2), len(streams.triangle), len(streams.noise)) == (1, 1, 1) + assert ( + len(streams.pulse2), + len(streams.triangle), + len(streams.noise), + ) == (1, 1, 1) def test_a_pitch_reaches_the_timer_the_table_states(self) -> None: streams = streams_from_instructions(one_channel(ChannelName.PULSE1), PLAYER_TIMER_TABLE) timer = PLAYER_TIMER_TABLE[PLAYER_REFERENCE_PITCH] - assert (streams.pulse1[0].timer_low, streams.pulse1[0].timer_high) == (timer & 0xFF, timer >> 8) + assert (streams.pulse1[0].timer_low, streams.pulse1[0].timer_high) == ( + timer & 0xFF, + timer >> 8, + ) def test_each_channel_reads_its_own_stream(self) -> None: instructions: Dict[ChannelName, List[InstructionUnion]] = { ChannelName.PULSE2: melody(), ChannelName.TRIANGLE: [TriangleInstruction(on=True, pitch=BASS_PITCH)], ChannelName.NOISE: [ - NoiseInstruction(on=True, period=NOISE_PERIOD, volume=NOISE_VOLUME, short=False), + NoiseInstruction( + on=True, + period=NOISE_PERIOD, + volume=NOISE_VOLUME, + short=False, + ), ], } streams = streams_from_instructions(instructions, PLAYER_TIMER_TABLE) @@ -115,7 +131,9 @@ def test_each_channel_reads_its_own_stream(self) -> None: assert streams.triangle[0].linear_counter == TRIANGLE_COUNTER_CONTROL | TRIANGLE_SOUNDING_RELOAD assert streams.noise[0].control & 0x0F == NOISE_VOLUME - def test_a_channel_holding_another_channels_instructions_raises(self) -> None: + def test_a_channel_holding_another_channels_instructions_raises( + self, + ) -> None: instructions: Dict[ChannelName, List[InstructionUnion]] = {ChannelName.TRIANGLE: melody()} with pytest.raises(TypeError): streams_from_instructions(instructions, PLAYER_TIMER_TABLE) @@ -131,7 +149,9 @@ def test_the_song_lasts_the_ticks_its_longest_channel_covers(self) -> None: == SOUNDING_TICKS + 1 ) - def test_the_schedule_follows_the_rate_the_reconstruction_was_built_at(self) -> None: + def test_the_schedule_follows_the_rate_the_reconstruction_was_built_at( + self, + ) -> None: reconstruction = player_reconstruction(one_channel(ChannelName.PULSE1), HALF_RATE_FREQUENCY) song = song_from_reconstruction(reconstruction, loop_tick=None, scheme=CompressionScheme.SEARCH) assert song.schedule == PlaySchedule.from_parameters(HALF_RATE_FREQUENCY) @@ -143,13 +163,22 @@ def test_the_song_carries_the_loop_it_is_given(self) -> None: def test_a_loop_beyond_the_songs_ticks_raises(self) -> None: reconstruction = player_reconstruction(one_channel(ChannelName.PULSE1), NTSC_FREQUENCY) with pytest.raises(ValueError): - song_from_reconstruction(reconstruction, loop_tick=SOUNDING_TICKS + 1, scheme=CompressionScheme.SEARCH) - - def test_the_timers_come_from_the_reconstructions_own_configuration(self) -> None: + song_from_reconstruction( + reconstruction, + loop_tick=SOUNDING_TICKS + 1, + scheme=CompressionScheme.SEARCH, + ) + + def test_the_timers_come_from_the_reconstructions_own_configuration( + self, + ) -> None: reconstruction = player_reconstruction(one_channel(ChannelName.PULSE1), NTSC_FREQUENCY) song = song_from_reconstruction(reconstruction, loop_tick=None, scheme=CompressionScheme.SEARCH) timer = get_timer_table(reconstruction.config.tuning)[PLAYER_REFERENCE_PITCH] - assert (song.streams.pulse1[0].timer_low, song.streams.pulse1[0].timer_high) == (timer & 0xFF, timer >> 8) + assert ( + song.streams.pulse1[0].timer_low, + song.streams.pulse1[0].timer_high, + ) == (timer & 0xFF, timer >> 8) def test_a_retuned_reconstruction_plays_retuned_timers(self) -> None: """The console reaches a pitch through a timer, so a reconstruction built against another @@ -164,9 +193,14 @@ def test_a_retuned_reconstruction_plays_retuned_timers(self) -> None: song = song_from_reconstruction(retuned, loop_tick=None, scheme=CompressionScheme.SEARCH) assert timer > PLAYER_TIMER_TABLE[PLAYER_REFERENCE_PITCH] - assert (song.streams.pulse1[0].timer_low, song.streams.pulse1[0].timer_high) == (timer & 0xFF, timer >> 8) + assert ( + song.streams.pulse1[0].timer_low, + song.streams.pulse1[0].timer_high, + ) == (timer & 0xFF, timer >> 8) - def test_a_reconstruction_describing_no_frame_plays_one_resting_tick(self) -> None: + def test_a_reconstruction_describing_no_frame_plays_one_resting_tick( + self, + ) -> None: reconstruction = player_reconstruction({ChannelName.PULSE1: [silent_pulse()]}, NTSC_FREQUENCY) song = song_from_reconstruction(reconstruction, loop_tick=None, scheme=CompressionScheme.SEARCH) assert song.ticks == 1 @@ -189,7 +223,9 @@ def test_a_slice_reaches_its_own_channel(self) -> None: instructions = instructions_from_instruments((lead(loop=False), bass(loop=False))) assert set(instructions) == {ChannelName.PULSE1, ChannelName.TRIANGLE} - def test_a_slice_reads_back_as_the_instruction_its_channel_sounds(self) -> None: + def test_a_slice_reads_back_as_the_instruction_its_channel_sounds( + self, + ) -> None: instructions = instructions_from_instruments((lead(loop=False), bass(loop=False))) assert all(isinstance(item, PulseInstruction) for item in instructions[ChannelName.PULSE1]) assert all(isinstance(item, TriangleInstruction) for item in instructions[ChannelName.TRIANGLE]) @@ -219,15 +255,27 @@ def test_a_request_carrying_no_slice_ends_the_song(self) -> None: class TestSongFromSample: """An export request read as the song the console plays it as.""" - def test_every_slice_sounds_on_the_channel_it_was_reconstructed_for(self) -> None: - song = sample_song(player_sample("demo", (lead(loop=False), bass(loop=False)), nes_frequency=NTSC_FREQUENCY)) + def test_every_slice_sounds_on_the_channel_it_was_reconstructed_for( + self, + ) -> None: + song = sample_song( + player_sample( + "demo", + (lead(loop=False), bass(loop=False)), + nes_frequency=NTSC_FREQUENCY, + ) + ) assert len(song.streams.pulse1) == SOUNDING_TICKS + 1 assert song.streams.triangle[0].linear_counter == TRIANGLE_COUNTER_CONTROL | TRIANGLE_SOUNDING_RELOAD assert set(song.streams.pulse2) == {channel_registers(ChannelName.PULSE2, {}, PLAYER_TIMER_TABLE)[0]} def test_a_slice_on_a_channel_the_song_leaves_out_rests(self) -> None: song = song_from_sample( - player_sample("demo", (lead(loop=False), bass(loop=False)), nes_frequency=NTSC_FREQUENCY), + player_sample( + "demo", + (lead(loop=False), bass(loop=False)), + nes_frequency=NTSC_FREQUENCY, + ), channels=frozenset({ChannelName.PULSE1}), loop_tick=None, scheme=CompressionScheme.SEARCH, @@ -251,16 +299,29 @@ def test_the_song_carries_the_loop_it_is_given(self) -> None: def test_the_timers_come_from_the_tuning_the_request_carries(self) -> None: song = sample_song(player_sample("demo", (lead(loop=False),), nes_frequency=NTSC_FREQUENCY)) timer = get_timer_table(Tuning())[PLAYER_REFERENCE_PITCH] - assert (song.streams.pulse1[0].timer_low, song.streams.pulse1[0].timer_high) == (timer & 0xFF, timer >> 8) + assert ( + song.streams.pulse1[0].timer_low, + song.streams.pulse1[0].timer_high, + ) == (timer & 0xFF, timer >> 8) def test_a_retuned_request_plays_retuned_timers(self) -> None: """The console reaches a pitch through a timer, so a request built against another concert pitch plays the divider that concert pitch names. """ tuning = Tuning(a4_frequency=RETUNED_A4_FREQUENCY) - song = sample_song(player_sample("demo", (lead(loop=False),), nes_frequency=NTSC_FREQUENCY, tuning=tuning)) + song = sample_song( + player_sample( + "demo", + (lead(loop=False),), + nes_frequency=NTSC_FREQUENCY, + tuning=tuning, + ) + ) timer = get_timer_table(tuning)[PLAYER_REFERENCE_PITCH] - assert (song.streams.pulse1[0].timer_low, song.streams.pulse1[0].timer_high) == (timer & 0xFF, timer >> 8) + assert ( + song.streams.pulse1[0].timer_low, + song.streams.pulse1[0].timer_high, + ) == (timer & 0xFF, timer >> 8) ROWS_PER_PATTERN: Final[int] = 4 @@ -300,7 +361,9 @@ def project_song(project: Project, loop_tick: Optional[int]) -> Song: class TestSongFromProject: """A whole project reaching the console as the four streams the driver plays.""" - def test_the_song_lasts_the_ticks_the_projects_groove_gives_its_rows(self) -> None: + def test_the_song_lasts_the_ticks_the_projects_groove_gives_its_rows( + self, + ) -> None: project = drum_project() song = project_song(project, loop_tick=None) assert song.ticks == SongTiming.from_project(project).frame_tick(project.song.order_length()) @@ -317,7 +380,10 @@ def test_every_channel_carries_the_songs_whole_length(self) -> None: def test_a_pitch_reaches_the_timer_the_tuning_names(self) -> None: song = project_song(drum_project(), loop_tick=None) timer = get_timer_table(Tuning())[PLAYER_REFERENCE_PITCH] - assert (song.streams.pulse1[0].timer_low, song.streams.pulse1[0].timer_high) == (timer & 0xFF, timer >> 8) + assert ( + song.streams.pulse1[0].timer_low, + song.streams.pulse1[0].timer_high, + ) == (timer & 0xFF, timer >> 8) def test_the_song_carries_the_loop_it_is_given(self) -> None: song = project_song(drum_project(), loop_tick=SONG_START) @@ -355,7 +421,12 @@ def test_the_song_writes_the_registers_the_hardest_scheme_writes(self, scheme: C def test_a_harder_scheme_takes_no_more_room(self) -> None: project = drum_project() sizes = [ - song_from_project(project, channels=ALL_CHANNELS, loop_tick=SONG_START, scheme=scheme).planes.size + song_from_project( + project, + channels=ALL_CHANNELS, + loop_tick=SONG_START, + scheme=scheme, + ).planes.size for scheme in CompressionScheme ] assert sizes == sorted(sizes, reverse=True) diff --git a/tests/unit/sampletones_player/test_instrument_song.py b/tests/unit/sampletones_player/test_instrument_song.py index 769aad1da..1d60bc17c 100644 --- a/tests/unit/sampletones_player/test_instrument_song.py +++ b/tests/unit/sampletones_player/test_instrument_song.py @@ -19,7 +19,9 @@ def _project() -> Project: instrument = Instrument( name="Lead", envelopes=InstrumentEnvelopes( - volume=Envelope(items=VOLUME), arpeggio=Envelope(items=(0, 4, 7)), duty_cycle=Envelope(items=(1,)) + volume=Envelope(items=VOLUME), + arpeggio=Envelope(items=(0, 4, 7)), + duty_cycle=Envelope(items=(1,)), ), ) project = Project.create(title="Demo", rows_per_pattern=ROWS_PER_PATTERN) @@ -34,17 +36,34 @@ class TestAnInstrumentReachesTheConsole: """The player reads the same walk the sequencer plays, so an instrument needs nothing of its own.""" def test_a_project_holding_an_instrument_compiles(self) -> None: - song = song_from_project(_project(), channels=ALL_CHANNELS, loop_tick=None, scheme=CompressionScheme.SEARCH) + song = song_from_project( + _project(), + channels=ALL_CHANNELS, + loop_tick=None, + scheme=CompressionScheme.SEARCH, + ) assert song.planes.ticks > 0 - def test_the_compiled_song_sounds_the_instrument_on_the_channel_it_was_placed_on(self) -> None: - song = song_from_project(_project(), channels=ALL_CHANNELS, loop_tick=None, scheme=CompressionScheme.SEARCH) + def test_the_compiled_song_sounds_the_instrument_on_the_channel_it_was_placed_on( + self, + ) -> None: + song = song_from_project( + _project(), + channels=ALL_CHANNELS, + loop_tick=None, + scheme=CompressionScheme.SEARCH, + ) levels = [registers.control & VOLUME_NIBBLE for registers in song.streams.pulse1[: len(VOLUME)]] assert levels == list(VOLUME) def test_the_channels_it_was_not_placed_on_stay_silent(self) -> None: - song = song_from_project(_project(), channels=ALL_CHANNELS, loop_tick=None, scheme=CompressionScheme.SEARCH) + song = song_from_project( + _project(), + channels=ALL_CHANNELS, + loop_tick=None, + scheme=CompressionScheme.SEARCH, + ) assert {registers.control & VOLUME_NIBBLE for registers in song.streams.pulse2} == {0} diff --git a/tests/unit/sampletones_player/test_song.py b/tests/unit/sampletones_player/test_song.py index 133dc586a..8e26e2a05 100644 --- a/tests/unit/sampletones_player/test_song.py +++ b/tests/unit/sampletones_player/test_song.py @@ -36,7 +36,11 @@ def test_a_song_may_stand_without_a_loop(self) -> None: def test_a_loop_at_the_songs_length_raises(self) -> None: with pytest.raises(ValidationError, match="loop_tick must lie within"): - player_song(resting_streams((SOUNDING, OCTAVE_UP)), NTSC_FREQUENCY, loop_tick=2) + player_song( + resting_streams((SOUNDING, OCTAVE_UP)), + NTSC_FREQUENCY, + loop_tick=2, + ) def test_a_negative_loop_raises(self) -> None: with pytest.raises(ValidationError, match="loop_tick must lie within"): @@ -70,10 +74,26 @@ def song(self) -> Song: ) test_cases: Tuple[TestCase, ...] = ( - TestCase(name="stops", loop_tick=None, expected=(0, 1, 2, 3, None, None, None, None)), - TestCase(name="repeats-from-the-start", loop_tick=0, expected=(0, 1, 2, 3, 0, 1, 2, 3)), - TestCase(name="repeats-from-the-middle", loop_tick=2, expected=(0, 1, 2, 3, 2, 3, 2, 3)), - TestCase(name="repeats-one-tick", loop_tick=3, expected=(0, 1, 2, 3, 3, 3, 3, 3)), + TestCase( + name="stops", + loop_tick=None, + expected=(0, 1, 2, 3, None, None, None, None), + ), + TestCase( + name="repeats-from-the-start", + loop_tick=0, + expected=(0, 1, 2, 3, 0, 1, 2, 3), + ), + TestCase( + name="repeats-from-the-middle", + loop_tick=2, + expected=(0, 1, 2, 3, 2, 3, 2, 3), + ), + TestCase( + name="repeats-one-tick", + loop_tick=3, + expected=(0, 1, 2, 3, 3, 3, 3, 3), + ), ) @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) @@ -89,9 +109,24 @@ def test_every_tick_played_lies_within_the_song(self, test_case: TestCase) -> No assert all(0 <= tick < song.ticks for tick in played if tick is not None) def test_the_song_lasts_as_long_as_its_streams(self) -> None: - song = player_song(resting_streams((SOUNDING, OCTAVE_UP)), NTSC_FREQUENCY, loop_tick=None) + song = player_song( + resting_streams((SOUNDING, OCTAVE_UP)), + NTSC_FREQUENCY, + loop_tick=None, + ) assert song.ticks == song.streams.ticks def test_a_slow_stream_holds_its_tick_between_calls(self) -> None: - song = player_song(resting_streams((SOUNDING, OCTAVE_UP, RESTING, RESTING)), 30, loop_tick=None) - assert tuple(song.tick_at(play_call) for play_call in range(6)) == (0, 0, 1, 1, 2, 2) + song = player_song( + resting_streams((SOUNDING, OCTAVE_UP, RESTING, RESTING)), + 30, + loop_tick=None, + ) + assert tuple(song.tick_at(play_call) for play_call in range(6)) == ( + 0, + 0, + 1, + 1, + 2, + 2, + ) From 07422959a62fbe2a484f3928c85bd37ee86f735a Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 21 Sep 2026 18:16:37 +0200 Subject: [PATCH 11/11] Read: every document in the encoding it is stored in --- src/sampletones_tools/player/assembler/labels.py | 3 ++- tests/integration/docs/test_documentation.py | 12 +++++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/sampletones_tools/player/assembler/labels.py b/src/sampletones_tools/player/assembler/labels.py index 32f7a9c4b..4001a2c21 100644 --- a/src/sampletones_tools/player/assembler/labels.py +++ b/src/sampletones_tools/player/assembler/labels.py @@ -6,6 +6,7 @@ from sampletones_shared.exceptions import DriverBuildError LABEL_MARKER: Final[str] = "al" +LABEL_ENCODING: Final[str] = "utf-8" LABEL_FIELDS: Final[int] = 3 SYMBOL_PREFIX: Final[str] = "." @@ -29,7 +30,7 @@ def read_labels(path: Path) -> Dict[str, int]: Dict[str, int]: Each symbol's address, keyed by the symbol's own name. """ labels: Dict[str, int] = {} - for line in path.read_text().splitlines(): + for line in path.read_text(encoding=LABEL_ENCODING).splitlines(): fields = line.split() if len(fields) == LABEL_FIELDS and fields[0] == LABEL_MARKER: labels[fields[2].removeprefix(SYMBOL_PREFIX)] = int( diff --git a/tests/integration/docs/test_documentation.py b/tests/integration/docs/test_documentation.py index 9edc2118e..9e2b3121e 100644 --- a/tests/integration/docs/test_documentation.py +++ b/tests/integration/docs/test_documentation.py @@ -6,6 +6,7 @@ from sampletones_shared.paths.source import REPOSITORY_ROOT +ENCODING: Final[str] = "utf-8" LINK: Final[re.Pattern[str]] = re.compile(r"\[[^\]]*\]\(([^)\s]+)\)") HEADING: Final[re.Pattern[str]] = re.compile(r"^#{1,6}\s+(.*?)\s*$", re.MULTILINE) EXTERNAL: Final[Tuple[str, ...]] = ("http://", "https://", "mailto:") @@ -20,8 +21,13 @@ def anchor(heading: str) -> str: return re.sub(r"[^\w\s-]", "", text).replace(" ", "-") +def text(page: Path) -> str: + """The page as it is stored, which is UTF-8 whatever encoding the platform prefers.""" + return page.read_text(encoding=ENCODING) + + def anchors(page: Path) -> Set[str]: - return {anchor(heading) for heading in HEADING.findall(page.read_text())} + return {anchor(heading) for heading in HEADING.findall(text(page))} def pages() -> List[Path]: @@ -37,7 +43,7 @@ class TestEveryInternalLinkResolves: @pytest.mark.parametrize("page", pages(), ids=identifier) def test_the_page_links_only_to_files_and_headings_that_exist(self, page: Path) -> None: - for target in LINK.findall(page.read_text()): + for target in LINK.findall(text(page)): if target.startswith(EXTERNAL): continue @@ -53,7 +59,7 @@ class TestTheIndexListsEveryDocument: """`docs/index.md` is the map of the documentation, so a page is reachable from it.""" def test_every_page_is_listed(self) -> None: - listed = {(INDEX.parent / target.partition("#")[0]).resolve() for target in LINK.findall(INDEX.read_text())} + listed = {(INDEX.parent / target.partition("#")[0]).resolve() for target in LINK.findall(text(INDEX))} missing = [ identifier(page) for page in DOCUMENTATION.rglob("*.md") if page != INDEX and page.resolve() not in listed ]