From ee963c38b3c3579bc0a5e5bbd2243a0c46c5d941 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 20 Sep 2026 01:29:00 +0200 Subject: [PATCH 1/7] Added: the Bitphase fields its loader reads --- docs/development/bugs-and-todos.md | 2 + .../formats/bitphase/builder.py | 4 ++ .../formats/bitphase/model/pattern.py | 32 +++++++-- .../formats/bitphase/model/song.py | 10 +++ .../formats/bitphase/model/table.py | 5 ++ .../formats/bitphase/specification/effects.py | 4 ++ .../bitphase/specification/instruments.py | 1 + tests/suite/bitphase.py | 66 +++++++++++++++++-- .../formats/bitphase/test_btp.py | 21 +++++- .../formats/bitphase/test_builder.py | 11 ++++ 10 files changed, 142 insertions(+), 14 deletions(-) diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 2015d2046..5e20e6037 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -32,6 +32,8 @@ starts carrying. * The bend in a Bitphase export. `formats/bitphase/envelopes.py` states the three dimensions it writes; `NesInstrumentRow` already carries `tone_add` and `tone_accumulation`, so the mapping is confined to that module. +* A Bitphase document states concert pitch whatever the reconstruction was tuned at: `_build_song` + builds the tuning table from `DEFAULT_A4_TUNING` and leaves `request.tuning` unread. * A transpose or a volume typed in the sample column of a row holding no sample reaches every channel. The column summarizes the channels its samples cover, and a row covering none falls back to all four so a value typed there lands somewhere; the reference slot keeps the narrower diff --git a/src/sampletones_core/formats/bitphase/builder.py b/src/sampletones_core/formats/bitphase/builder.py index ba8bc48c4..9308723e5 100644 --- a/src/sampletones_core/formats/bitphase/builder.py +++ b/src/sampletones_core/formats/bitphase/builder.py @@ -196,6 +196,7 @@ def _build_song( *, speed: int, nes_frequency: int, + pattern_length: int, ) -> BitphaseSong: chip_frequency = CPU_FREQUENCIES[DEFAULT_CHIP_VARIANT] return BitphaseSong( @@ -205,6 +206,7 @@ def _build_song( a4_tuning=DEFAULT_A4_TUNING, ), initial_speed=speed, + default_pattern_length=pattern_length, chip_frequency=chip_frequency, interrupt_frequency=nes_frequency, ) @@ -297,6 +299,7 @@ def sample_to_bitphase(request: SampleExport) -> BitphaseProject: patterns, speed=PREVIEW_SPEED, nes_frequency=request.nes_frequency, + pattern_length=length, ), ), pattern_order=order, @@ -586,6 +589,7 @@ def project_to_bitphase(project: Project) -> BitphaseProject: patterns, speed=groove.ticks[GROOVE_TRIGGER_ROW], nes_frequency=settings.nes_frequency, + pattern_length=project.song.rows_per_pattern, ), ), pattern_order=tuple(pattern.id for pattern in patterns), diff --git a/src/sampletones_core/formats/bitphase/model/pattern.py b/src/sampletones_core/formats/bitphase/model/pattern.py index 98a61c2c6..c2f4792ed 100644 --- a/src/sampletones_core/formats/bitphase/model/pattern.py +++ b/src/sampletones_core/formats/bitphase/model/pattern.py @@ -1,8 +1,12 @@ from typing import Dict, Optional, Tuple -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, computed_field from sampletones_core.formats.bitphase.model.config import BITPHASE_MODEL_CONFIG +from sampletones_core.formats.bitphase.specification.effects import ( + MIN_EFFECT_COLUMNS, + NO_EFFECT_TABLE, +) from sampletones_core.formats.bitphase.specification.patterns import ( EMPTY_OCTAVE, FULL_VOLUME, @@ -35,16 +39,22 @@ class NoteCell(BaseModel): class EffectCell(BaseModel): - """One effect column of a pattern row.""" + """One effect column of a pattern row. + + Bitphase reads an effect from a table wherever the cell names an index of zero or + above, and from the cell's own parameter otherwise, so a parameter-driven effect + states ``NO_EFFECT_TABLE``. + """ model_config = BITPHASE_MODEL_CONFIG effect: int = Field(..., description="Effect identifier.") delay: int = Field(default=0, description="Ticks the effect waits before it applies.") parameter: int = Field(default=0, description="Effect argument.") - table_index: Optional[int] = Field( - default=None, - description="Table the effect drives, where it takes one.", + table_index: int = Field( + default=NO_EFFECT_TABLE, + ge=NO_EFFECT_TABLE, + description="Table the effect drives, or NO_EFFECT_TABLE where its parameter drives it.", ) @@ -77,13 +87,23 @@ class BitphaseRow(BaseModel): class BitphaseChannel(BaseModel): - """One channel's lines within a pattern.""" + """One channel's lines within a pattern. + + A channel lays out as many effect columns as its widest line carries, and Bitphase + holds that width across every pattern the channel appears in. + """ model_config = BITPHASE_MODEL_CONFIG rows: Tuple[BitphaseRow, ...] = Field(..., description="One row per pattern line.") label: str = Field(..., description="Name of the channel the lines drive.") + @computed_field # type: ignore[prop-decorator] + @property + def effect_column_count(self) -> int: + """How many effect columns the channel's lines fill.""" + return max((len(row.effects) for row in self.rows), default=MIN_EFFECT_COLUMNS) + class BitphasePattern(BaseModel): """One block of tracker lines across every channel. diff --git a/src/sampletones_core/formats/bitphase/model/song.py b/src/sampletones_core/formats/bitphase/model/song.py index bd85a11b9..4d27ed61f 100644 --- a/src/sampletones_core/formats/bitphase/model/song.py +++ b/src/sampletones_core/formats/bitphase/model/song.py @@ -12,6 +12,10 @@ MIN_INITIAL_SPEED, ChipVariant, ) +from sampletones_core.formats.bitphase.specification.patterns import ( + MAX_PATTERN_LENGTH, + MIN_PATTERN_LENGTH, +) class BitphaseSong(BaseModel): @@ -38,6 +42,12 @@ class BitphaseSong(BaseModel): le=MAX_INITIAL_SPEED, description="Engine ticks per pattern line.", ) + default_pattern_length: int = Field( + ..., + ge=MIN_PATTERN_LENGTH, + le=MAX_PATTERN_LENGTH, + description="Line count a pattern added to the song takes.", + ) chip_type: str = Field( default=CHIP_TYPE_NES, description="Chip the song drives.", diff --git a/src/sampletones_core/formats/bitphase/model/table.py b/src/sampletones_core/formats/bitphase/model/table.py index 65243d4e8..1be0778f3 100644 --- a/src/sampletones_core/formats/bitphase/model/table.py +++ b/src/sampletones_core/formats/bitphase/model/table.py @@ -4,6 +4,7 @@ from sampletones_core.formats.bitphase.model.config import BITPHASE_MODEL_CONFIG from sampletones_core.formats.bitphase.specification.instruments import ( + ABSOLUTE_TABLE, LOOP_FROM_START, MAX_TABLE_ID, MIN_TABLE_ID, @@ -42,3 +43,7 @@ class BitphaseTable(BaseModel): ..., description="Name shown in the table list.", ) + additive: bool = Field( + default=ABSOLUTE_TABLE, + description="Whether each step adds to the value the table has reached, rather than to the note.", + ) diff --git a/src/sampletones_core/formats/bitphase/specification/effects.py b/src/sampletones_core/formats/bitphase/specification/effects.py index 04404c644..01dd8aafd 100644 --- a/src/sampletones_core/formats/bitphase/specification/effects.py +++ b/src/sampletones_core/formats/bitphase/specification/effects.py @@ -14,3 +14,7 @@ class EffectId(IntEnum): SPEED_EFFECT_DELAY: Final[int] = 0 NO_EFFECT_PARAMETER: Final[int] = 0 +NO_EFFECT_TABLE: Final[int] = -1 + +MIN_EFFECT_COLUMNS: Final[int] = 1 +MAX_EFFECT_COLUMNS: Final[int] = 4 diff --git a/src/sampletones_core/formats/bitphase/specification/instruments.py b/src/sampletones_core/formats/bitphase/specification/instruments.py index 2dfb06911..91f6e12d7 100644 --- a/src/sampletones_core/formats/bitphase/specification/instruments.py +++ b/src/sampletones_core/formats/bitphase/specification/instruments.py @@ -48,3 +48,4 @@ LOOP_FROM_START: Final[int] = 0 NO_TABLE_OFFSET: Final[int] = 0 +ABSOLUTE_TABLE: Final[bool] = False diff --git a/tests/suite/bitphase.py b/tests/suite/bitphase.py index c511104d6..eb575305f 100644 --- a/tests/suite/bitphase.py +++ b/tests/suite/bitphase.py @@ -22,7 +22,10 @@ BITPHASE_DEFAULT_EFFECT: Final[int] = 0 BITPHASE_DEFAULT_EFFECT_DELAY: Final[int] = 0 BITPHASE_DEFAULT_EFFECT_PARAMETER: Final[int] = 0 +BITPHASE_FIRST_TABLE_INDEX: Final[int] = 0 BITPHASE_NO_EFFECTS: Final[Tuple[None, ...]] = (None,) +BITPHASE_MIN_EFFECT_COLUMNS: Final[int] = 1 +BITPHASE_MAX_EFFECT_COLUMNS: Final[int] = 4 BITPHASE_DEFAULT_INSTRUMENT_ID: Final[str] = "01" BITPHASE_DEFAULT_LOOP: Final[int] = 0 BITPHASE_DEFAULT_TABLE_ID: Final[int] = 0 @@ -31,6 +34,8 @@ MIN_INITIAL_SPEED: Final[int] = 1 MAX_INITIAL_SPEED: Final[int] = 255 +MIN_PATTERN_LENGTH: Final[int] = 1 +MAX_PATTERN_LENGTH: Final[int] = 256 @dataclass(frozen=True) @@ -60,6 +65,7 @@ class LoadedRow: class LoadedChannel: label: str rows: List[LoadedRow] + effect_column_count: int @dataclass(frozen=True) @@ -103,6 +109,7 @@ class LoadedTable: loop: int name: str rows: List[int] + additive: bool @dataclass(frozen=True) @@ -113,6 +120,7 @@ class LoadedSong: interrupt_frequency: int a4_tuning_hz: float initial_speed: int + default_pattern_length: int tuning_table: List[int] patterns: List[LoadedPattern] @@ -136,6 +144,23 @@ def _note(data: Optional[Dict[str, Any]]) -> LoadedNote: ) +def _table_index(data: Dict[str, Any]) -> Optional[int]: + """The table an effect reads, which Bitphase takes from any index of zero or above. + + A cell stating no index at all is driven by its own parameter, and one carrying an + empty value reads as the first table, since that is what the comparison Bitphase + makes says of it. + """ + if "tableIndex" not in data: + return None + + index = data["tableIndex"] + if index is None: + return BITPHASE_FIRST_TABLE_INDEX + + return index if index >= BITPHASE_FIRST_TABLE_INDEX else None + + def _effect(data: Optional[Dict[str, Any]]) -> Optional[LoadedEffect]: if data is None: return None @@ -144,7 +169,7 @@ def _effect(data: Optional[Dict[str, Any]]) -> Optional[LoadedEffect]: effect=data.get("effect", BITPHASE_DEFAULT_EFFECT), delay=data.get("delay", BITPHASE_DEFAULT_EFFECT_DELAY), parameter=data.get("parameter", BITPHASE_DEFAULT_EFFECT_PARAMETER), - table_index=data.get("tableIndex"), + table_index=_table_index(data), ) @@ -166,12 +191,30 @@ def _row(data: Dict[str, Any]) -> LoadedRow: ) -def _channel(data: Dict[str, Any], label: str) -> LoadedChannel: - rows = data.get("rows") - if rows is None: - return LoadedChannel(label=label, rows=[]) +def _effect_column_count(data: Dict[str, Any], rows: List[LoadedRow]) -> int: + """How many effect columns a channel lays out, which its widest line states. + + Bitphase takes the count the channel carries where it holds one, and reads it off the + lines otherwise, so a channel written without the field lays out as many columns as its + lines fill. + """ + stated = data.get("effectColumnCount") + if isinstance(stated, int): + return min(max(stated, BITPHASE_MIN_EFFECT_COLUMNS), BITPHASE_MAX_EFFECT_COLUMNS) + + return max( + (len(row.effects) for row in rows), + default=BITPHASE_MIN_EFFECT_COLUMNS, + ) - return LoadedChannel(label=label, rows=[_row(row) for row in rows]) + +def _channel(data: Dict[str, Any], label: str) -> LoadedChannel: + rows = [_row(row) for row in data.get("rows") or []] + return LoadedChannel( + label=label, + rows=rows, + effect_column_count=_effect_column_count(data, rows), + ) def _pattern(data: Dict[str, Any], labels: List[str]) -> LoadedPattern: @@ -219,6 +262,7 @@ def _table(data: Dict[str, Any]) -> LoadedTable: loop=data.get("loop", BITPHASE_DEFAULT_LOOP), name=data.get("name", BITPHASE_DEFAULT_NAME), rows=list(data.get("rows") or []), + additive=bool(data.get("additive", False)), ) @@ -230,6 +274,15 @@ def _initial_speed(data: Dict[str, Any]) -> int: return BITPHASE_DEFAULT_INITIAL_SPEED +def _default_pattern_length(data: Dict[str, Any]) -> int: + """The line count a pattern added to the song takes, within the range Bitphase keeps.""" + length = data.get("defaultPatternLength") + if isinstance(length, int) and MIN_PATTERN_LENGTH <= length <= MAX_PATTERN_LENGTH: + return length + + return BITPHASE_DEFAULT_PATTERN_LENGTH + + def _song(data: Dict[str, Any], labels: List[str]) -> LoadedSong: return LoadedSong( chip_type=data.get("chipType"), @@ -238,6 +291,7 @@ def _song(data: Dict[str, Any], labels: List[str]) -> LoadedSong: interrupt_frequency=data.get("interruptFrequency", BITPHASE_DEFAULT_INTERRUPT_FREQUENCY), a4_tuning_hz=data.get("a4TuningHz", BITPHASE_DEFAULT_A4_TUNING), initial_speed=_initial_speed(data), + default_pattern_length=_default_pattern_length(data), tuning_table=list(data.get("tuningTable") or []), patterns=[_pattern(pattern, labels) for pattern in data.get("patterns") or []], ) diff --git a/tests/unit/sampletones_core/formats/bitphase/test_btp.py b/tests/unit/sampletones_core/formats/bitphase/test_btp.py index 1c65bdd76..34530645b 100644 --- a/tests/unit/sampletones_core/formats/bitphase/test_btp.py +++ b/tests/unit/sampletones_core/formats/bitphase/test_btp.py @@ -34,6 +34,7 @@ "patterns", "tuningTable", "initialSpeed", + "defaultPatternLength", "chipType", "chipVariant", "chipFrequency", @@ -56,7 +57,7 @@ "sweepRate", "sweepShift", ] -TABLE_KEYS: Final[List[str]] = ["id", "rows", "loop", "name"] +TABLE_KEYS: Final[List[str]] = ["id", "rows", "loop", "name", "additive"] @pytest.fixture(name="project") @@ -134,7 +135,23 @@ def test_a_note_names_a_semitone_and_an_octave(self, document: Dict[str, Any]) - def test_a_channel_names_the_channel_it_drives(self, document: Dict[str, Any]) -> None: channel = document["songs"][0]["patterns"][0]["channels"][0] - assert set(channel) == {"rows", "label"} + assert set(channel) == {"rows", "label", "effectColumnCount"} + + def test_every_effect_names_the_table_it_reads_or_none(self, document: Dict[str, Any]) -> None: + """Bitphase reads an effect from a table wherever the cell names an index of zero or + above, and an empty value counts as an index, so a parameter-driven effect states one + below. + """ + cells = [ + effect + for song in document["songs"] + for pattern in song["patterns"] + for channel in pattern["channels"] + for row in channel["rows"] + for effect in row["effects"] + if effect is not None + ] + assert all(isinstance(effect["tableIndex"], int) for effect in cells) class TestTheDocumentReadsAsNes: diff --git a/tests/unit/sampletones_core/formats/bitphase/test_builder.py b/tests/unit/sampletones_core/formats/bitphase/test_builder.py index 424525221..2580e56f0 100644 --- a/tests/unit/sampletones_core/formats/bitphase/test_builder.py +++ b/tests/unit/sampletones_core/formats/bitphase/test_builder.py @@ -154,6 +154,17 @@ def test_every_channel_of_the_pattern_is_as_long_as_the_pattern(self, project: B pattern = project.songs[0].patterns[0] assert all(len(channel.rows) == pattern.length for channel in pattern.channels) + def test_the_song_states_the_length_its_patterns_were_built_at(self, project: BitphaseProject) -> None: + song = project.songs[0] + assert song.default_pattern_length == song.patterns[0].length + + def test_a_channel_lays_out_a_column_for_every_effect_its_lines_carry( + self, + project: BitphaseProject, + ) -> None: + channels = project.songs[0].patterns[0].channels + assert all(channel.effect_column_count == max(len(row.effects) for row in channel.rows) for channel in channels) + class TestTheOrderCoversTheLongestInstrument: """Playback returns to the start of the order, so a document whose order runs out From e0362be73400b26c176042cdfdeb2de1f3b8bd40 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 20 Sep 2026 01:38:43 +0200 Subject: [PATCH 2/7] Shared: the envelope limit between the formats that cap a dimension --- src/sampletones_core/features/limits.py | 33 +++++++++ .../formats/famitracker/sequences/features.py | 23 ++---- .../sampletones_core/features/test_limits.py | 72 +++++++++++++++++++ 3 files changed, 110 insertions(+), 18 deletions(-) create mode 100644 src/sampletones_core/features/limits.py create mode 100644 tests/unit/sampletones_core/features/test_limits.py diff --git a/src/sampletones_core/features/limits.py b/src/sampletones_core/features/limits.py new file mode 100644 index 000000000..22b4e1987 --- /dev/null +++ b/src/sampletones_core/features/limits.py @@ -0,0 +1,33 @@ +from sampletones_core.constants.enums import FeatureKey +from sampletones_core.features.envelope import Envelope, releases + + +def within_limit(feature_key: FeatureKey, envelope: Envelope[int], limit: int) -> Envelope[int]: + """One dimension within the items a format stores, the release a volume ends on among them. + + A dimension carries whatever length it was written at, and a format bounds how many items it + stores of it, so this is where the two meet. A dimension keeps its opening items, and a volume + dimension ending at silence keeps that silence as its last item — the release is what ends a + note, so it is the one item worth a place of its own. + + Args: + feature_key: The dimension being written. + envelope: The dimension as the instrument carries it. + limit: The most items the format stores. + + Returns: + Envelope[int]: The dimension within that limit. + """ + if feature_key is FeatureKey.VOLUME and releases(envelope): + return _keeping_release(envelope, limit) + + return envelope.limited(limit) + + +def _keeping_release(envelope: Envelope[int], limit: int) -> Envelope[int]: + """This dimension within ``limit`` items, the last of them the release it ends on.""" + if len(envelope.items) <= limit: + return envelope + + opening = envelope.limited(limit - 1) + return opening.with_items(opening.items + envelope.items[-1:]) diff --git a/src/sampletones_core/formats/famitracker/sequences/features.py b/src/sampletones_core/formats/famitracker/sequences/features.py index b3711e135..bfc591369 100644 --- a/src/sampletones_core/formats/famitracker/sequences/features.py +++ b/src/sampletones_core/formats/famitracker/sequences/features.py @@ -3,7 +3,8 @@ from sampletones_core.constants.enums import FeatureKey from sampletones_core.exporters.feature import Features from sampletones_core.exporters.truncation import EnvelopeTruncation -from sampletones_core.features.envelope import Envelope, releases +from sampletones_core.features.envelope import Envelope +from sampletones_core.features.limits import within_limit from sampletones_core.formats.famitracker.model.sequence import InstrumentSequence from sampletones_core.formats.famitracker.specification.sequences import ( BEND_SEQUENCE_KINDS, @@ -46,10 +47,8 @@ def stored_envelope( ) -> Envelope[int]: """One dimension as a FamiTracker file holds it, within the items a sequence stores. - The item limit belongs to the file: an envelope carries whatever length it was written at, and - this is where a longer one meets what the format stores. A dimension over the limit keeps its - opening items, and a volume dimension ending at silence keeps that silence as its last item — - the release is what ends a note, so it is the one item worth a place of its own. + The item limit belongs to the file, and :func:`within_limit` is the rule every format shortens + a dimension by. Args: feature_key: The dimension being written. @@ -58,10 +57,7 @@ def stored_envelope( Returns: Envelope[int]: The dimension within the items the file holds. """ - if feature_key is FeatureKey.VOLUME and releases(envelope): - return _keeping_release(envelope, MAX_SEQUENCE_ITEMS) - - return envelope.limited(MAX_SEQUENCE_ITEMS) + return within_limit(feature_key, envelope, MAX_SEQUENCE_ITEMS) def is_shortened(feature_key: FeatureKey, envelope: Envelope[int]) -> bool: @@ -160,15 +156,6 @@ def _stored_envelopes(features: Features) -> Dict[SequenceKind, Envelope[int]]: } -def _keeping_release(envelope: Envelope[int], limit: int) -> Envelope[int]: - """This dimension within ``limit`` items, the last of them the release it ends on.""" - if len(envelope.items) <= limit: - return envelope - - opening = envelope.limited(limit - 1) - return opening.with_items(opening.items + envelope.items[-1:]) - - def _sequence( kind: SequenceKind, envelope: Envelope[int], diff --git a/tests/unit/sampletones_core/features/test_limits.py b/tests/unit/sampletones_core/features/test_limits.py new file mode 100644 index 000000000..263cfc7ca --- /dev/null +++ b/tests/unit/sampletones_core/features/test_limits.py @@ -0,0 +1,72 @@ +from typing import Final, List, Optional, Sequence + +from sampletones_core.constants.enums import FeatureKey +from sampletones_core.features.envelope import Envelope +from sampletones_core.features.limits import within_limit + +LIMIT: Final[int] = 4 +RELEASE: Final[int] = 0 +SOUNDING_LEVEL: Final[int] = 8 + + +def envelope(items: Sequence[int], loop_point: Optional[int] = None) -> Envelope[int]: + return Envelope[int](items=tuple(items), loop_point=loop_point) + + +def released(length: int) -> List[int]: + return [SOUNDING_LEVEL] * (length - 1) + [RELEASE] + + +def sounding(length: int) -> List[int]: + return [SOUNDING_LEVEL] * length + + +class TestADimensionWithinTheLimit: + def test_a_shorter_dimension_is_kept_whole(self) -> None: + source = envelope(sounding(LIMIT - 1)) + assert within_limit(FeatureKey.VOLUME, source, LIMIT) == source + + def test_a_dimension_at_the_limit_is_kept_whole(self) -> None: + source = envelope(released(LIMIT)) + assert within_limit(FeatureKey.VOLUME, source, LIMIT) == source + + def test_a_longer_dimension_keeps_its_opening_items(self) -> None: + source = sounding(LIMIT + 3) + assert within_limit(FeatureKey.ARPEGGIO, envelope(source), LIMIT).items == tuple(source[:LIMIT]) + + def test_the_point_it_repeats_from_stays_within_what_is_kept(self) -> None: + kept = within_limit(FeatureKey.ARPEGGIO, envelope(sounding(LIMIT + 3), loop_point=LIMIT + 1), LIMIT) + assert kept.loop_point == LIMIT - 1 + + +class TestTheReleaseAVolumeEndsOn: + """A volume dimension ending at silence is what releases a note, so a format keeps that item. + + A dimension halting on its last item holds that value for as long as the note sounds, so a + shortened volume that dropped its silence would sound on. + """ + + def test_a_released_dimension_past_the_limit_still_ends_at_its_release(self) -> None: + kept = within_limit(FeatureKey.VOLUME, envelope(released(LIMIT + 1)), LIMIT) + assert len(kept.items) == LIMIT + assert kept.items[-1] == RELEASE + + def test_the_release_displaces_the_last_item_that_would_not_fit(self) -> None: + source = released(LIMIT + 1) + kept = within_limit(FeatureKey.VOLUME, envelope(source), LIMIT) + assert kept.items == tuple(source[: LIMIT - 1]) + (RELEASE,) + + def test_a_dimension_that_goes_on_sounding_keeps_its_opening_items(self) -> None: + source = sounding(LIMIT + 3) + kept = within_limit(FeatureKey.VOLUME, envelope(source), LIMIT) + assert kept.items == tuple(source[:LIMIT]) + + def test_a_circling_dimension_reads_its_final_silence_as_part_of_the_cycle(self) -> None: + source = released(LIMIT + 1) + kept = within_limit(FeatureKey.VOLUME, envelope(source, loop_point=0), LIMIT) + assert kept.items == tuple(source[:LIMIT]) + + def test_a_dimension_other_than_volume_keeps_its_opening_items(self) -> None: + source = released(LIMIT + 1) + kept = within_limit(FeatureKey.ARPEGGIO, envelope(source), LIMIT) + assert kept.items == tuple(source[:LIMIT]) From 0a99c789ec225a49f85fb6a5c486249d99049dbc Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 20 Sep 2026 01:58:05 +0200 Subject: [PATCH 3/7] Changed: Bitphase instruments written as per-dimension macros --- .../exports/implementation/bitphase.py | 8 +- .../formats/bitphase/builder.py | 11 +- .../formats/bitphase/envelopes.py | 167 ++++++++-------- .../formats/bitphase/macros.py | 42 ++++ .../formats/bitphase/model/instrument.py | 181 ++++++------------ .../formats/bitphase/preset.py | 23 +-- .../formats/bitphase/specification/macros.py | 48 +++++ .../integration/bitphase/test_btp_pipeline.py | 75 +++++--- tests/suite/bitphase.py | 69 ++++--- .../sampletones_core/exports/test_bitphase.py | 18 +- .../formats/bitphase/test_btp.py | 44 ++--- .../formats/bitphase/test_envelopes.py | 138 +++++++------ .../bitphase/test_instrument_document.py | 24 ++- .../formats/bitphase/test_preset.py | 54 ++++-- 14 files changed, 505 insertions(+), 397 deletions(-) create mode 100644 src/sampletones_core/formats/bitphase/macros.py create mode 100644 src/sampletones_core/formats/bitphase/specification/macros.py diff --git a/src/sampletones_core/exports/implementation/bitphase.py b/src/sampletones_core/exports/implementation/bitphase.py index ba5df71b6..473f6509e 100644 --- a/src/sampletones_core/exports/implementation/bitphase.py +++ b/src/sampletones_core/exports/implementation/bitphase.py @@ -35,8 +35,8 @@ class BitphaseBackend: A ``.btp`` holds a whole document, so every scope lands in one file: an instrument and a reconstruction each become a playable document whose pattern triggers the - instruments it carries. Bitphase stores instrument and table rows without a length - limit, so every envelope crosses over whole. + instruments it carries. A macro holds the values of one dimension, up to the 512 a + Bitphase instrument stores, and a table carries a contour of any length. """ @property @@ -92,8 +92,8 @@ class BitphasePresetBackend: The panel reads one instrument per file into the slot the user has selected, so a whole reconstruction lands as a set of them beside the chosen destination, one file - per channel slice named after the instrument. A preset carries rows alone, so its - pitch contour rides in each row's tone offset. + per channel slice named after the instrument. A preset carries macros alone, so its + pitch contour rides in the tone offset each tick takes. """ @property diff --git a/src/sampletones_core/formats/bitphase/builder.py b/src/sampletones_core/formats/bitphase/builder.py index 9308723e5..dc5a5fa3c 100644 --- a/src/sampletones_core/formats/bitphase/builder.py +++ b/src/sampletones_core/formats/bitphase/builder.py @@ -86,11 +86,11 @@ class SliceVoice: Attributes: number: Value a pattern's instrument column carries to play the instrument. - instrument: The per-tick rows the channel takes on. + instrument: The macros the channel reads a value per tick from. table: The per-tick semitone contour that moves the note. channel: The NES channel the slice was reconstructed for. initial_pitch: Pitch the slice's contour is measured against. - ticks: How many ticks the instrument runs before it loops. + ticks: How many ticks the instrument runs before every dimension stands at its end. """ number: int @@ -135,19 +135,18 @@ def _build_slice_voice( number=number, instrument=BitphaseInstrument( id=format_instrument_id(number), - rows=envelopes.rows, - loop=envelopes.loop, + macros=envelopes.macros, name=name, ), table=BitphaseTable( id=table_id, rows=envelopes.table_rows, - loop=envelopes.loop, + loop=envelopes.table_loop, name=name, ), channel=channel, initial_pitch=initial_pitch, - ticks=len(envelopes.rows), + ticks=envelopes.ticks, ) diff --git a/src/sampletones_core/formats/bitphase/envelopes.py b/src/sampletones_core/formats/bitphase/envelopes.py index 4984383ee..80869845e 100644 --- a/src/sampletones_core/formats/bitphase/envelopes.py +++ b/src/sampletones_core/formats/bitphase/envelopes.py @@ -1,10 +1,11 @@ from dataclasses import dataclass -from typing import Final, Iterable, Tuple +from typing import Dict, Tuple from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.exporters.feature import Features from sampletones_core.features.envelope import Envelope -from sampletones_core.formats.bitphase.model.instrument import NesInstrumentRow +from sampletones_core.formats.bitphase.macros import macro, stored_envelope +from sampletones_core.formats.bitphase.model.instrument import InstrumentMacro from sampletones_core.formats.bitphase.notes import noise_arpeggio_to_table_offset from sampletones_core.formats.bitphase.specification.instruments import ( FLAT_PULSE_WIDTH, @@ -15,30 +16,33 @@ NOISE_MODE_SHORT, SILENT_VOLUME, ) +from sampletones_core.formats.bitphase.specification.macros import NesMacroField -SILENT_ROW: Final[NesInstrumentRow] = NesInstrumentRow( - pulse_width=FLAT_PULSE_WIDTH, - volume_or_rate=SILENT_VOLUME, -) +SILENT_VOLUME_ENVELOPE = Envelope[int](items=(SILENT_VOLUME,)) +HELD_VOLUME_ENVELOPE = Envelope[int](items=(MAX_VOLUME_OR_RATE,)) +FLAT_CONTOUR = Envelope[int](items=(NO_TABLE_OFFSET,)) + +MacroBag = Dict[NesMacroField, InstrumentMacro] @dataclass(frozen=True) class ChannelEnvelopes: """One channel slice expressed the way Bitphase plays it back. - The instrument rows and the table rows advance on their own per-tick counters, so - they share a length and a loop point and stay in step for as long as the note - sounds. + An instrument's macros and its table advance on per-tick counters of their own, so each + carries the length and the repeat point its own dimension asks for. Attributes: - rows: Instrument rows, one per engine tick. + macros: One macro per instrument field the slice decides. table_rows: Semitone offsets, one per engine tick. - loop: Row both lists return to once they run off the end. + table_loop: Step the contour circles from once it runs off the end. + ticks: Ticks the longest dimension describes. """ - rows: Tuple[NesInstrumentRow, ...] + macros: MacroBag table_rows: Tuple[int, ...] - loop: int + table_loop: int + ticks: int def _pulse_width(channel: ChannelName, duty_cycle: int) -> int: @@ -63,37 +67,70 @@ def _table_offset(channel: ChannelName, arpeggio: int) -> int: return arpeggio -def _held_volume(frames: int) -> Tuple[int, ...]: - """The volume envelope of a slice whose level the channel governs. +def _volume_envelope(features: Features) -> Envelope[int]: + """The levels an instrument writes, which a slice leaving its volume alone holds full. - Bitphase combines each row's level with the pattern's volume column, and a full-level - row comes out at the column's own level, so an instrument holding one for every frame - it describes sounds at whatever level the channel carries. + Bitphase combines each row's level with the pattern's volume column, and a full level + comes out at the column's own level, so an instrument holding one sounds at whatever + level the channel carries. + """ + volume = features.envelopes.get(FeatureKey.VOLUME, Envelope[int]()) + if volume.written: + return stored_envelope(FeatureKey.VOLUME, volume) - Args: - frames: The frames the slice describes. + return HELD_VOLUME_ENVELOPE - Returns: - Tuple[int, ...]: One full-level item per frame. - """ - return (MAX_VOLUME_OR_RATE,) * frames + +def _waveform_envelope(features: Features, channel: ChannelName) -> Envelope[int]: + """The waveform values a channel takes, which a slice leaving its duty alone holds flat.""" + duty_cycle = stored_envelope( + FeatureKey.DUTY_CYCLE, + features.envelopes.get(FeatureKey.DUTY_CYCLE, Envelope[int]()), + ) + items = duty_cycle.items or (FLAT_PULSE_WIDTH,) + return duty_cycle.with_items(tuple(_pulse_width(channel, item) for item in items)) + + +def _contour(features: Features, channel: ChannelName) -> Envelope[int]: + """The semitone steps the table moves the note by, flat where the slice states none.""" + arpeggio = features.envelopes.get(FeatureKey.ARPEGGIO, Envelope[int]()) + if not arpeggio.written: + return FLAT_CONTOUR + + return arpeggio.with_items(tuple(_table_offset(channel, step) for step in arpeggio.items)) + + +def _silent_slice(features: Features, channel: ChannelName) -> ChannelEnvelopes: + """The smallest instrument Bitphase plays, which is what a slice describing no frame writes.""" + return ChannelEnvelopes( + macros=_macro_bag(SILENT_VOLUME_ENVELOPE, features, channel), + table_rows=FLAT_CONTOUR.items, + table_loop=LOOP_FROM_START, + ticks=0, + ) + + +def _macro_bag(volume: Envelope[int], features: Features, channel: ChannelName) -> MacroBag: + """The fields the slice decides, the triangle channel leaving its one waveform alone.""" + macros = {NesMacroField.VOLUME_OR_RATE: macro(volume)} + if channel != ChannelName.TRIANGLE: + macros[NesMacroField.PULSE_WIDTH] = macro(_waveform_envelope(features, channel)) + + return macros def features_to_envelopes( features: Features, channel: ChannelName, ) -> ChannelEnvelopes: - """Converts one channel slice's envelopes into Bitphase instrument and table rows. + """Converts one channel slice's envelopes into the macros and table Bitphase reads it from. Volume becomes the instrument's per-tick level, the duty cycle becomes the channel's - waveform field, and the arpeggio becomes the table contour that moves the note. A - slice that leaves its volume to the channel takes a full level for every frame it - describes, so the channel governs how loud it sounds. Bitphase reads every dimension out of - one row, so the instrument returns to the earliest row any dimension repeats from; one whose - dimensions all halt returns to its last row, resting on the level its volume envelope ends - with — silence where the slice writes its own, the channel's level where it holds one. - - A slice describing no frame comes back as the one silent row that is the smallest + waveform field, and the arpeggio becomes the table contour that moves the note. Each + keeps the length and the repeat point it was written at, so a dimension holding one + value all through costs that one value. + + A slice describing no frame comes back as the one silent value that is the smallest instrument Bitphase plays. Args: @@ -101,57 +138,31 @@ def features_to_envelopes( channel: The NES channel the slice was reconstructed for. Returns: - ChannelEnvelopes: The rows, contour, and loop point describing the slice. + ChannelEnvelopes: The macros, contour and repeat points describing the slice. """ - frames = features.frame_count - envelopes = { - feature_key: features.envelopes.get(feature_key, Envelope[int]()).resized(frames) - for feature_key in (FeatureKey.VOLUME, FeatureKey.ARPEGGIO, FeatureKey.DUTY_CYCLE) - } - - if not frames: - return ChannelEnvelopes( - rows=(SILENT_ROW,), - table_rows=(NO_TABLE_OFFSET,), - loop=LOOP_FROM_START, - ) - - volumes = envelopes[FeatureKey.VOLUME].items or _held_volume(frames) - arpeggios = envelopes[FeatureKey.ARPEGGIO].items - duty_cycles = envelopes[FeatureKey.DUTY_CYCLE].items - - rows = tuple( - NesInstrumentRow( - pulse_width=_pulse_width(channel, duty_cycles[frame] if duty_cycles else FLAT_PULSE_WIDTH), - volume_or_rate=volume, - ) - for frame, volume in enumerate(volumes) - ) - contour = arpeggios or (NO_TABLE_OFFSET,) * len(volumes) - table_rows = tuple(_table_offset(channel, arpeggio) for arpeggio in contour) + if not features.frame_count: + return _silent_slice(features, channel) + + contour = _contour(features, channel) + macros = _macro_bag(_volume_envelope(features), features, channel) return ChannelEnvelopes( - rows=rows, - table_rows=table_rows, - loop=_loop_row(envelopes.values(), len(rows)), + macros=macros, + table_rows=contour.items, + table_loop=_repeat_point(contour), + ticks=_ticks(macros, contour), ) -def _loop_row(envelopes: Iterable[Envelope[int]], rows: int) -> int: - """The row a Bitphase instrument returns to, which is the earliest any dimension repeats from. - - Bitphase reads every dimension out of one row, so one point serves them all and the earliest - keeps each dimension sounding what it would have sounded. +def _repeat_point(envelope: Envelope[int]) -> int: + """The step a dimension circles from, which is its last where it states no point of its own.""" + if envelope.loop_point is not None: + return envelope.loop_point - Args: - envelopes: The dimensions the instrument writes. - rows: How many rows the instrument holds. + return len(envelope.items) - 1 - Returns: - int: The row to return to, held inside the rows the instrument carries. - """ - points = [envelope.loop_point for envelope in envelopes if envelope.loop_point is not None] - if not points: - return rows - 1 - return min(*points, rows - 1) +def _ticks(macros: MacroBag, contour: Envelope[int]) -> int: + """How long the instrument runs before every dimension it writes stands at its end.""" + lengths = [len(written.values) for written in macros.values()] + return max(lengths + [len(contour.items)]) diff --git a/src/sampletones_core/formats/bitphase/macros.py b/src/sampletones_core/formats/bitphase/macros.py new file mode 100644 index 000000000..6c61ac5d1 --- /dev/null +++ b/src/sampletones_core/formats/bitphase/macros.py @@ -0,0 +1,42 @@ +from sampletones_core.constants.enums import FeatureKey +from sampletones_core.features.envelope import Envelope +from sampletones_core.features.limits import within_limit +from sampletones_core.formats.bitphase.model.instrument import InstrumentMacro +from sampletones_core.formats.bitphase.specification.macros import MAX_MACRO_LENGTH + + +def stored_envelope(feature_key: FeatureKey, envelope: Envelope[int]) -> Envelope[int]: + """One dimension as a Bitphase instrument holds it, within the values a macro stores. + + The value limit belongs to the tracker, and :func:`within_limit` is the rule every format + shortens a dimension by. + + Args: + feature_key: The dimension being written. + envelope: The dimension as the instrument carries it. + + Returns: + Envelope[int]: The dimension within the values a macro holds. + """ + return within_limit(feature_key, envelope, MAX_MACRO_LENGTH) + + +def macro(envelope: Envelope[int]) -> InstrumentMacro: + """One dimension as the macro Bitphase reads it from. + + Playback circles from the macro's loop index once the values run out, so a dimension + holding its final value states that value's own index, and one circling from a point + states the point. A dimension longer than a macro stores keeps its opening values. + + Args: + envelope: The dimension to write. + + Returns: + InstrumentMacro: The macro carrying that dimension. + + Raises: + ValueError: If the dimension writes no value at all. + """ + stored = envelope.limited(MAX_MACRO_LENGTH) + loop = stored.loop_point if stored.loop_point is not None else len(stored.items) - 1 + return InstrumentMacro(values=stored.items, loop=loop) diff --git a/src/sampletones_core/formats/bitphase/model/instrument.py b/src/sampletones_core/formats/bitphase/model/instrument.py index 37cb71aff..eef128ba7 100644 --- a/src/sampletones_core/formats/bitphase/model/instrument.py +++ b/src/sampletones_core/formats/bitphase/model/instrument.py @@ -1,154 +1,99 @@ -from typing import Tuple +from __future__ import annotations -from pydantic import BaseModel, Field +from typing import Dict, Tuple + +from pydantic import BaseModel, Field, model_validator from sampletones_core.formats.bitphase.model.config import BITPHASE_MODEL_CONFIG from sampletones_core.formats.bitphase.specification.chip import CHIP_TYPE_NES -from sampletones_core.formats.bitphase.specification.instruments import ( - ABSOLUTE_TONE, - CONSTANT_VOLUME, - KEEP_PHASE, - LOOP_FROM_START, - MAX_PULSE_WIDTH, - MAX_SOUND_LENGTH, - MAX_SWEEP_RATE, - MAX_SWEEP_SHIFT, - MAX_TONE_ADD, - MAX_VOLUME_OR_RATE, - MIN_PULSE_WIDTH, - MIN_SOUND_LENGTH, - MIN_SWEEP_RATE, - MIN_SWEEP_SHIFT, - MIN_TONE_ADD, - MIN_VOLUME_OR_RATE, - NO_SWEEP, - NO_SWEEP_RATE, - NO_SWEEP_SHIFT, - NO_TONE_OFFSET, - SUSTAINED_SOUND_LENGTH, +from sampletones_core.formats.bitphase.specification.instruments import LOOP_FROM_START +from sampletones_core.formats.bitphase.specification.macros import ( + MAX_MACRO_LENGTH, + MIN_MACRO_LENGTH, + NES_MACRO_FIELDS, + NesMacroField, ) -class NesInstrumentRow(BaseModel): - """One tick of a Bitphase NES instrument. +class InstrumentMacro(BaseModel): + """One field of an instrument, read a value per engine tick. - An instrument advances one row per engine tick, so a row carries every register - value the channel takes for that tick. ``pulse_width`` selects the duty on a square - channel and the LFSR mode on the noise channel; ``volume_or_rate`` is a literal - volume while ``envelope`` stays off. The remaining fields hold the settings a - reconstruction leaves alone: the note sustains, the pitch comes from the tuning - table, and the hardware sweep stays disabled. + Every field advances on a counter of its own, so a macro carries the length and the + repeat point that field alone asks for. ``loop`` indexes the values, and playback + circles from it once the values run out — which makes the whole macro circle where it + stands at the first value, and the last value hold where it stands at the last. """ model_config = BITPHASE_MODEL_CONFIG - pulse_width: int = Field( - ..., - ge=MIN_PULSE_WIDTH, - le=MAX_PULSE_WIDTH, - description="Square duty cycle, or the noise channel's LFSR mode.", - ) - volume_or_rate: int = Field( + values: Tuple[int, ...] = Field( ..., - ge=MIN_VOLUME_OR_RATE, - le=MAX_VOLUME_OR_RATE, - description="Channel volume while the hardware envelope stays off.", - ) - retrigger: bool = Field( - default=KEEP_PHASE, - description="Restarts the waveform phase this tick.", - ) - sound_length: int = Field( - default=SUSTAINED_SOUND_LENGTH, - ge=MIN_SOUND_LENGTH, - le=MAX_SOUND_LENGTH, - description="Length counter in ticks; zero holds the note for as long as the envelope runs.", - ) - envelope: bool = Field( - default=CONSTANT_VOLUME, - description="Reads volume_or_rate as a decay rate.", - ) - tone_add: int = Field( - default=NO_TONE_OFFSET, - ge=MIN_TONE_ADD, - le=MAX_TONE_ADD, - description="Offset added to the tuning-table period on a square or triangle channel.", + min_length=MIN_MACRO_LENGTH, + max_length=MAX_MACRO_LENGTH, + description="Value the field takes on each tick.", ) - tone_accumulation: bool = Field( - default=ABSOLUTE_TONE, - description="Sums tone_add across ticks.", - ) - sweep: bool = Field( - default=NO_SWEEP, - description="Enables the square channel's sweep unit.", - ) - sweep_rate: int = Field( - default=NO_SWEEP_RATE, - ge=MIN_SWEEP_RATE, - le=MAX_SWEEP_RATE, - ) - sweep_shift: int = Field( - default=NO_SWEEP_SHIFT, - ge=MIN_SWEEP_SHIFT, - le=MAX_SWEEP_SHIFT, + loop: int = Field( + default=LOOP_FROM_START, + ge=0, + description="Index among the values playback circles from.", ) + @model_validator(mode="after") + def _check_loop(self) -> InstrumentMacro: + if self.loop >= len(self.values): + raise ValueError(f"loop {self.loop} stands past the {len(self.values)} values written") -class BitphaseInstrument(BaseModel): - """A named row list one pattern cell triggers, held in a project's instrument list. + return self - ``id`` is the base-36 text a pattern's instrument column matches on, and ``loop`` - is the row playback returns to once it runs off the end. + +class MacroInstrument(BaseModel): + """The fields every stored Bitphase instrument carries. + + An instrument states a macro for each field whose values it decides, and Bitphase reads + every other field it offers at that field's own default. """ model_config = BITPHASE_MODEL_CONFIG - id: str = Field( - ..., - description="Base-36 identifier a pattern row references.", - ) chip_type: str = Field( default=CHIP_TYPE_NES, - description="Chip whose row layout the instrument uses.", - ) - rows: Tuple[NesInstrumentRow, ...] = Field( - ..., - description="One row per engine tick.", - ) - loop: int = Field( - default=LOOP_FROM_START, - ge=0, - description="Row playback returns to after the last row.", + description="Chip whose fields the macros drive.", ) name: str = Field( ..., description="Name shown in the instrument list.", ) + macros: Dict[NesMacroField, InstrumentMacro] = Field( + ..., + description="One macro per field the instrument decides.", + ) + @model_validator(mode="after") + def _check_values(self) -> MacroInstrument: + for field, macro in self.macros.items(): + spec = NES_MACRO_FIELDS[field] + for value in macro.values: + if not spec.minimum <= value <= spec.maximum: + raise ValueError(f"{field} holds {value}, outside the {spec.minimum}..{spec.maximum} it takes") -class BitphaseInstrumentPreset(BaseModel): - """A single instrument as Bitphase's instruments panel loads and saves it. + return self - The panel writes the loaded rows into the instrument slot the user has selected, - which supplies the id and leaves this file carrying the rows alone. - """ - model_config = BITPHASE_MODEL_CONFIG +class BitphaseInstrument(MacroInstrument): + """A named instrument one pattern cell triggers, held in a project's instrument list. - chip_type: str = Field( - default=CHIP_TYPE_NES, - description="Chip whose row layout the preset uses.", - ) - name: str = Field( - ..., - description="Name the preset offers for the instrument.", - ) - loop: int = Field( - default=LOOP_FROM_START, - ge=0, - description="Row playback returns to after the last row.", - ) - rows: Tuple[NesInstrumentRow, ...] = Field( + ``id`` is the base-36 text a pattern's instrument column matches on. + """ + + id: str = Field( ..., - description="One row per engine tick.", + description="Base-36 identifier a pattern row references.", ) + + +class BitphaseInstrumentPreset(MacroInstrument): + """A single instrument as Bitphase's instruments panel loads and saves it. + + The panel writes the loaded macros into the instrument slot the user has selected, + which supplies the id and the chip and leaves this file carrying the macros alone. + """ diff --git a/src/sampletones_core/formats/bitphase/preset.py b/src/sampletones_core/formats/bitphase/preset.py index 1d2275450..51fbb6673 100644 --- a/src/sampletones_core/formats/bitphase/preset.py +++ b/src/sampletones_core/formats/bitphase/preset.py @@ -4,8 +4,10 @@ from sampletones_core.constants.enums import ChannelName from sampletones_core.exports.request import InstrumentExport +from sampletones_core.features.envelope import Envelope from sampletones_core.formats.bitphase.envelopes import features_to_envelopes -from sampletones_core.formats.bitphase.model.instrument import BitphaseInstrumentPreset, NesInstrumentRow +from sampletones_core.formats.bitphase.macros import macro +from sampletones_core.formats.bitphase.model.instrument import BitphaseInstrumentPreset from sampletones_core.formats.bitphase.notes import pitch_to_note_index from sampletones_core.formats.bitphase.specification.chip import DEFAULT_A4_TUNING, DEFAULT_CPU_FREQUENCY from sampletones_core.formats.bitphase.specification.instruments import ( @@ -13,6 +15,7 @@ MIN_TONE_ADD, NO_TONE_OFFSET, ) +from sampletones_core.formats.bitphase.specification.macros import NesMacroField from sampletones_core.formats.bitphase.specification.patterns import MAX_NOTE_INDEX, MIN_NOTE_INDEX from sampletones_core.formats.bitphase.tuning import generate_tuning_table @@ -30,12 +33,11 @@ def _tone_offsets( ) -> Tuple[int, ...]: """Expresses a semitone contour as the per-tick period offsets a preset carries. - A preset holds rows alone, so its pitch movement rides in each row's tone offset. + A preset holds macros alone, so its pitch movement rides in the tone offset each tick takes. The offsets are measured against the pitch the slice was reconstructed at, under the tuning the NTSC system gives at concert pitch, which is what a freshly created Bitphase document plays. The noise channel takes its period from the note rather - than from a period offset, so its rows hold a flat offset and the note carries the - pitch. + than from a period offset, so its offsets stay flat and the note carries the pitch. """ if channel == ChannelName.NOISE: return (NO_TONE_OFFSET,) * len(contour) @@ -70,18 +72,13 @@ def instrument_to_preset(request: InstrumentExport) -> BitphaseInstrumentPreset: request.features.initial_pitch, envelopes.table_rows, ) - rows: Tuple[NesInstrumentRow, ...] = tuple( - row.model_copy(update={"tone_add": offset}) - for row, offset in zip( - envelopes.rows, - offsets, - ) - ) return BitphaseInstrumentPreset( name=request.name, - loop=envelopes.loop, - rows=rows, + macros={ + **envelopes.macros, + NesMacroField.TONE_ADD: macro(Envelope[int](items=offsets, loop_point=envelopes.table_loop)), + }, ) diff --git a/src/sampletones_core/formats/bitphase/specification/macros.py b/src/sampletones_core/formats/bitphase/specification/macros.py new file mode 100644 index 000000000..529e2853d --- /dev/null +++ b/src/sampletones_core/formats/bitphase/specification/macros.py @@ -0,0 +1,48 @@ +from dataclasses import dataclass +from enum import StrEnum +from typing import Final, Mapping + +from sampletones_core.formats.bitphase.specification.instruments import ( + MAX_PULSE_WIDTH, + MAX_TONE_ADD, + MAX_VOLUME_OR_RATE, + MIN_PULSE_WIDTH, + MIN_TONE_ADD, + MIN_VOLUME_OR_RATE, +) + + +class NesMacroField(StrEnum): + """Instrument field a Bitphase NES macro drives, named as the instrument keys it. + + Bitphase reads each field it offers from the macro carrying that name, and gives a field + the instrument leaves out a default of its own, so an instrument states the fields whose + values it decides. These are the three a reconstruction decides. + """ + + VOLUME_OR_RATE = "volumeOrRate" + PULSE_WIDTH = "pulseWidth" + TONE_ADD = "toneAdd" + + +@dataclass(frozen=True) +class MacroFieldSpec: + """The values a field accepts. + + Attributes: + minimum: Lowest value the field takes. + maximum: Highest value the field takes. + """ + + minimum: int + maximum: int + + +NES_MACRO_FIELDS: Final[Mapping[NesMacroField, MacroFieldSpec]] = { + NesMacroField.VOLUME_OR_RATE: MacroFieldSpec(MIN_VOLUME_OR_RATE, MAX_VOLUME_OR_RATE), + NesMacroField.PULSE_WIDTH: MacroFieldSpec(MIN_PULSE_WIDTH, MAX_PULSE_WIDTH), + NesMacroField.TONE_ADD: MacroFieldSpec(MIN_TONE_ADD, MAX_TONE_ADD), +} + +MIN_MACRO_LENGTH: Final[int] = 1 +MAX_MACRO_LENGTH: Final[int] = 512 diff --git a/tests/integration/bitphase/test_btp_pipeline.py b/tests/integration/bitphase/test_btp_pipeline.py index fc96efa83..76a3de5b8 100644 --- a/tests/integration/bitphase/test_btp_pipeline.py +++ b/tests/integration/bitphase/test_btp_pipeline.py @@ -30,7 +30,12 @@ MAX_VOLUME_OR_RATE, MIN_PULSE_WIDTH, MIN_VOLUME_OR_RATE, - SUSTAINED_SOUND_LENGTH, + SILENT_VOLUME, +) +from sampletones_core.formats.bitphase.specification.macros import ( + MAX_MACRO_LENGTH, + MIN_MACRO_LENGTH, + NesMacroField, ) from sampletones_core.formats.bitphase.specification.patterns import ( FIRST_OCTAVE, @@ -127,7 +132,11 @@ def test_patterns_cover_the_played_channels(self, document: LoadedProject) -> No assert triggered == set(PLAYED_CHANNELS) def test_the_document_carries_audible_volume(self, document: LoadedProject) -> None: - assert any(row.volume_or_rate > 0 for instrument in document.instruments for row in instrument.rows) + assert any( + value > SILENT_VOLUME + for instrument in document.instruments + for value in instrument.macro(NesMacroField.VOLUME_OR_RATE).values + ) class TestTheLoaderReadsWhatWasWritten: @@ -139,7 +148,7 @@ class TestTheLoaderReadsWhatWasWritten: def test_the_song_names_the_chip_it_drives(self, document: LoadedProject) -> None: assert document.songs[0].chip_type == CHIP_TYPE_NES - def test_every_instrument_names_the_chip_whose_rows_it_holds(self, document: LoadedProject) -> None: + def test_every_instrument_names_the_chip_whose_fields_it_holds(self, document: LoadedProject) -> None: assert {instrument.chip_type for instrument in document.instruments} == {CHIP_TYPE_NES} def test_the_song_carries_the_clock_its_tuning_was_built_from(self, document: LoadedProject) -> None: @@ -292,37 +301,45 @@ def test_a_tempo_the_speed_column_states_leaves_every_effect_column_empty( assert all(row.effects == list(BITPHASE_NO_EFFECTS) for row in every_row(document)) -class TestTheInstrumentRowsArePlayable: - def test_every_row_holds_a_waveform_the_channel_reads(self, document: LoadedProject) -> None: - rows = [row for instrument in document.instruments for row in instrument.rows] - assert all(MIN_PULSE_WIDTH <= row.pulse_width <= MAX_PULSE_WIDTH for row in rows) - - def test_every_row_holds_a_level_the_channel_reads(self, document: LoadedProject) -> None: - rows = [row for instrument in document.instruments for row in instrument.rows] - assert all(MIN_VOLUME_OR_RATE <= row.volume_or_rate <= MAX_VOLUME_OR_RATE for row in rows) +class TestTheInstrumentMacrosArePlayable: + def test_every_waveform_value_is_one_the_channel_reads(self, document: LoadedProject) -> None: + values = [ + value for instrument in document.instruments for value in instrument.macro(NesMacroField.PULSE_WIDTH).values + ] + assert all(MIN_PULSE_WIDTH <= value <= MAX_PULSE_WIDTH for value in values) - def test_every_row_reads_its_level_as_a_literal_volume(self, document: LoadedProject) -> None: - rows = [row for instrument in document.instruments for row in instrument.rows] - assert all(row.envelope is False for row in rows) + def test_every_level_is_one_the_channel_reads(self, document: LoadedProject) -> None: + values = [ + value + for instrument in document.instruments + for value in instrument.macro(NesMacroField.VOLUME_OR_RATE).values + ] + assert all(MIN_VOLUME_OR_RATE <= value <= MAX_VOLUME_OR_RATE for value in values) - def test_every_row_holds_the_note_for_as_long_as_the_envelope_runs(self, document: LoadedProject) -> None: - rows = [row for instrument in document.instruments for row in instrument.rows] - assert all(row.sound_length == SUSTAINED_SOUND_LENGTH for row in rows) + def test_the_fields_a_reconstruction_leaves_alone_are_left_out(self, document: LoadedProject) -> None: + """Bitphase reads a field an instrument states no macro for at that field's own default, + which is the literal volume, the sustained note and the silent sweep a reconstruction asks + for. + """ + written = {field for instrument in document.instruments for field in instrument.macros} + assert written.isdisjoint({"envelope", "soundLength", "sweep", "toneAccumulation"}) - def test_every_instrument_loops_on_a_row_it_holds(self, document: LoadedProject) -> None: - """Playback returns to the loop row once it runs off the end, so a loop point - past the last row would leave the instrument nowhere to resume from. + def test_every_macro_circles_from_a_value_it_holds(self, document: LoadedProject) -> None: + """Playback circles from the loop index once the values run out, so a point past + them would leave the field nowhere to resume from. """ - assert all(instrument.loop < len(instrument.rows) for instrument in document.instruments) + assert all( + macro.loop < len(macro.values) + for instrument in document.instruments + for macro in instrument.macros.values() + ) def test_every_table_loops_on_a_row_it_holds(self, document: LoadedProject) -> None: assert all(table.loop < len(table.rows) for table in document.tables) - def test_each_instrument_runs_as_long_as_its_table(self, document: LoadedProject) -> None: - """The rows and the table advance on their own per-tick counters, so a length - they share is what keeps the volume envelope aligned with the pitch contour. - """ - lengths = [ - (len(instrument.rows), len(table.rows)) for instrument, table in zip(document.instruments, document.tables) - ] - assert all(rows == table_rows for rows, table_rows in lengths) + def test_every_macro_holds_the_values_a_bitphase_instrument_stores(self, document: LoadedProject) -> None: + assert all( + MIN_MACRO_LENGTH <= len(macro.values) <= MAX_MACRO_LENGTH + for instrument in document.instruments + for macro in instrument.macros.values() + ) diff --git a/tests/suite/bitphase.py b/tests/suite/bitphase.py index eb575305f..cb4b6b6ca 100644 --- a/tests/suite/bitphase.py +++ b/tests/suite/bitphase.py @@ -29,8 +29,19 @@ BITPHASE_DEFAULT_INSTRUMENT_ID: Final[str] = "01" BITPHASE_DEFAULT_LOOP: Final[int] = 0 BITPHASE_DEFAULT_TABLE_ID: Final[int] = 0 -BITPHASE_DEFAULT_PULSE_WIDTH: Final[int] = 2 -BITPHASE_DEFAULT_VOLUME_OR_RATE: Final[int] = 15 +BITPHASE_MAX_MACRO_LENGTH: Final[int] = 512 +BITPHASE_MACRO_DEFAULTS: Final[Dict[str, Any]] = { + "pulseWidth": 2, + "volumeOrRate": 15, + "envelope": False, + "retrigger": False, + "soundLength": 0, + "toneAdd": 0, + "toneAccumulation": False, + "sweep": False, + "sweepRate": 0, + "sweepShift": 0, +} MIN_INITIAL_SPEED: Final[int] = 1 MAX_INITIAL_SPEED: Final[int] = 255 @@ -76,32 +87,34 @@ class LoadedPattern: @dataclass(frozen=True) -class LoadedInstrumentRow: - pulse_width: int - volume_or_rate: int - envelope: bool - sound_length: int - tone_add: int - tone_accumulation: bool - retrigger: bool - sweep: bool - sweep_rate: int - sweep_shift: int +class LoadedMacro: + values: List[Any] + loop: int @dataclass(frozen=True) class LoadedInstrument: id: str chip_type: str - loop: int name: str - rows: List[LoadedInstrumentRow] + macros: Dict[str, LoadedMacro] @property def number(self) -> int: """The value a pattern's instrument column carries to play this instrument.""" return int(self.id, 36) + def macro(self, field: str) -> LoadedMacro: + """The macro Bitphase reads a field from, which one the instrument leaves out defaults. + + Args: + field: The instrument field, named as Bitphase keys it. + + Returns: + LoadedMacro: The field's own macro, or the single default value it takes. + """ + return self.macros.get(field, LoadedMacro(values=[BITPHASE_MACRO_DEFAULTS[field]], loop=0)) + @dataclass(frozen=True) class LoadedTable: @@ -229,19 +242,16 @@ def _pattern(data: Dict[str, Any], labels: List[str]) -> LoadedPattern: ) -def _instrument_row(data: Dict[str, Any]) -> LoadedInstrumentRow: - return LoadedInstrumentRow( - pulse_width=data.get("pulseWidth", BITPHASE_DEFAULT_PULSE_WIDTH), - volume_or_rate=data.get("volumeOrRate", BITPHASE_DEFAULT_VOLUME_OR_RATE), - envelope=bool(data.get("envelope", False)), - sound_length=data.get("soundLength", 0), - tone_add=data.get("toneAdd", 0), - tone_accumulation=bool(data.get("toneAccumulation", False)), - retrigger=bool(data.get("retrigger", False)), - sweep=bool(data.get("sweep", False)), - sweep_rate=data.get("sweepRate", 0), - sweep_shift=data.get("sweepShift", 0), - ) +def _macro(data: Dict[str, Any]) -> LoadedMacro: + """One macro as Bitphase resolves it, within the values it stores and the loop they hold.""" + values = list(data.get("values") or [])[:BITPHASE_MAX_MACRO_LENGTH] + loop = data.get("loop", BITPHASE_DEFAULT_LOOP) + return LoadedMacro(values=values, loop=min(max(loop, 0), max(len(values) - 1, 0))) + + +def _macros(data: Dict[str, Any]) -> Dict[str, LoadedMacro]: + macros = data.get("macros") or {} + return {field: _macro(macro) for field, macro in macros.items()} def _instrument(data: Dict[str, Any]) -> LoadedInstrument: @@ -250,9 +260,8 @@ def _instrument(data: Dict[str, Any]) -> LoadedInstrument: return LoadedInstrument( id=identifier if isinstance(identifier, str) else BITPHASE_DEFAULT_INSTRUMENT_ID, chip_type=chip_type if isinstance(chip_type, str) else BITPHASE_DEFAULT_CHIP_TYPE, - loop=data.get("loop", BITPHASE_DEFAULT_LOOP), name=data.get("name", BITPHASE_DEFAULT_NAME), - rows=[_instrument_row(row) for row in data.get("rows") or []], + macros=_macros(data), ) diff --git a/tests/unit/sampletones_core/exports/test_bitphase.py b/tests/unit/sampletones_core/exports/test_bitphase.py index 8cb5466e6..a054f116a 100644 --- a/tests/unit/sampletones_core/exports/test_bitphase.py +++ b/tests/unit/sampletones_core/exports/test_bitphase.py @@ -21,6 +21,7 @@ ) from sampletones_core.exports.scope import ExportScope from sampletones_core.features.envelope import Envelope +from sampletones_core.formats.bitphase.specification.macros import MAX_MACRO_LENGTH, NesMacroField from sampletones_core.project.project import Project from sampletones_core.project.settings import ProjectSettings from sampletones_shared.music import Tuning @@ -116,17 +117,22 @@ def test_the_document_holds_the_slice(self, backend: BitphaseBackend, tmp_path: assert [instrument["name"] for instrument in document["instruments"]] == ["Short"] - def test_a_long_envelope_crosses_over_whole(self, backend: BitphaseBackend, tmp_path: Path) -> None: - """Bitphase stores instrument rows without a length limit, so a reconstruction - reaches the document at its full length. + def test_a_long_envelope_reaches_the_values_a_macro_stores( + self, + backend: BitphaseBackend, + tmp_path: Path, + ) -> None: + """A macro holds the values of one dimension, and a longer envelope keeps its opening + values, while the contour a table carries crosses over whole. """ destination = tmp_path / f"Long{EXT_FILE_BITPHASE}" - artifact = backend.write_instrument(destination, build_instrument("Long", LONG_ENVELOPE_FRAMES)) + backend.write_instrument(destination, build_instrument("Long", LONG_ENVELOPE_FRAMES)) document = read_document(destination) - assert len(document["instruments"][0]["rows"]) == LONG_ENVELOPE_FRAMES - assert artifact.truncation is None + macros = document["instruments"][0]["macros"] + assert len(macros[NesMacroField.VOLUME_OR_RATE]["values"]) == MAX_MACRO_LENGTH + assert len(document["tables"][0]["rows"]) == LONG_ENVELOPE_FRAMES class TestWriteSample: diff --git a/tests/unit/sampletones_core/formats/bitphase/test_btp.py b/tests/unit/sampletones_core/formats/bitphase/test_btp.py index 34530645b..671f386b5 100644 --- a/tests/unit/sampletones_core/formats/bitphase/test_btp.py +++ b/tests/unit/sampletones_core/formats/bitphase/test_btp.py @@ -44,19 +44,8 @@ ] PATTERN_KEYS: Final[List[str]] = ["id", "length", "channels", "patternRows"] ROW_KEYS: Final[List[str]] = ["note", "effects", "instrument", "table", "volume"] -INSTRUMENT_KEYS: Final[List[str]] = ["id", "chipType", "rows", "loop", "name"] -INSTRUMENT_ROW_KEYS: Final[List[str]] = [ - "pulseWidth", - "volumeOrRate", - "retrigger", - "soundLength", - "envelope", - "toneAdd", - "toneAccumulation", - "sweep", - "sweepRate", - "sweepShift", -] +INSTRUMENT_KEYS: Final[List[str]] = ["id", "chipType", "name", "macros"] +MACRO_FIELDS: Final[List[str]] = ["volumeOrRate", "pulseWidth"] TABLE_KEYS: Final[List[str]] = ["id", "rows", "loop", "name", "additive"] @@ -121,9 +110,13 @@ def test_the_row_holds_its_field(self, document: Dict[str, Any], key: str) -> No def test_the_instrument_holds_its_field(self, document: Dict[str, Any], key: str) -> None: assert key in document["instruments"][0] - @pytest.mark.parametrize("key", INSTRUMENT_ROW_KEYS) - def test_the_instrument_row_holds_its_field(self, document: Dict[str, Any], key: str) -> None: - assert key in document["instruments"][0]["rows"][0] + @pytest.mark.parametrize("field", MACRO_FIELDS) + def test_the_instrument_states_the_fields_it_decides(self, document: Dict[str, Any], field: str) -> None: + assert field in document["instruments"][0]["macros"] + + def test_a_macro_carries_its_values_and_the_point_they_circle_from(self, document: Dict[str, Any]) -> None: + macro = document["instruments"][0]["macros"]["volumeOrRate"] + assert set(macro) == {"values", "loop"} @pytest.mark.parametrize("key", TABLE_KEYS) def test_the_table_holds_its_field(self, document: Dict[str, Any], key: str) -> None: @@ -171,20 +164,17 @@ def test_the_order_names_patterns_the_song_holds(self, document: Dict[str, Any]) class TestTheEnvelopesSurvive: def test_the_volume_envelope_crosses_over_whole(self, document: Dict[str, Any]) -> None: - rows = document["instruments"][0]["rows"] - assert [row["volumeOrRate"] for row in rows] == VOLUME_ENVELOPE + assert document["instruments"][0]["macros"]["volumeOrRate"]["values"] == VOLUME_ENVELOPE def test_the_pitch_contour_crosses_over_whole(self, document: Dict[str, Any]) -> None: assert document["tables"][0]["rows"] == PITCH_CONTOUR def test_the_noise_mode_reaches_the_waveform_field(self, document: Dict[str, Any]) -> None: - rows = document["instruments"][1]["rows"] - assert [row["pulseWidth"] for row in rows] == [1, 1, 0, 0] + assert document["instruments"][1]["macros"]["pulseWidth"]["values"] == [1, 1, 0, 0] - def test_the_rows_read_their_level_as_a_literal_volume(self, document: Dict[str, Any]) -> None: - rows = document["instruments"][0]["rows"] - assert all(row["envelope"] is False for row in rows) - - def test_the_rows_hold_the_note_for_as_long_as_the_envelope_runs(self, document: Dict[str, Any]) -> None: - rows = document["instruments"][0]["rows"] - assert all(row["soundLength"] == 0 for row in rows) + def test_the_fields_a_reconstruction_leaves_alone_are_left_out(self, document: Dict[str, Any]) -> None: + """Bitphase reads a field the instrument states no macro for at its own default, which is + the literal volume, the sustained note and the silent sweep a reconstruction asks for. + """ + written = set(document["instruments"][0]["macros"]) + assert written.isdisjoint({"envelope", "soundLength", "sweep", "toneAccumulation"}) diff --git a/tests/unit/sampletones_core/formats/bitphase/test_envelopes.py b/tests/unit/sampletones_core/formats/bitphase/test_envelopes.py index 3662685c4..d6d92dec4 100644 --- a/tests/unit/sampletones_core/formats/bitphase/test_envelopes.py +++ b/tests/unit/sampletones_core/formats/bitphase/test_envelopes.py @@ -18,6 +18,7 @@ NOISE_MODE_SHORT, SILENT_VOLUME, ) +from sampletones_core.formats.bitphase.specification.macros import MAX_MACRO_LENGTH, NesMacroField from .conftest import build_features, looping @@ -32,7 +33,6 @@ class PulseWidthCase: PULSE_WIDTH_CASES: List[PulseWidthCase] = [ PulseWidthCase(channel=ChannelName.PULSE1, duty_cycle=2, pulse_width=2), PulseWidthCase(channel=ChannelName.PULSE2, duty_cycle=3, pulse_width=3), - PulseWidthCase(channel=ChannelName.TRIANGLE, duty_cycle=3, pulse_width=FLAT_PULSE_WIDTH), PulseWidthCase(channel=ChannelName.NOISE, duty_cycle=0, pulse_width=NOISE_MODE_LONG), PulseWidthCase(channel=ChannelName.NOISE, duty_cycle=1, pulse_width=NOISE_MODE_SHORT), ] @@ -41,13 +41,21 @@ class PulseWidthCase: PITCH_CONTOUR: Final[List[int]] = [0, 2, 4, 5, 7] -class TestRowsCarryTheEnvelopes: - def test_each_volume_item_becomes_one_row(self) -> None: +def volume_values(envelopes: ChannelEnvelopes) -> List[int]: + return list(envelopes.macros[NesMacroField.VOLUME_OR_RATE].values) + + +def waveform_values(envelopes: ChannelEnvelopes) -> List[int]: + return list(envelopes.macros[NesMacroField.PULSE_WIDTH].values) + + +class TestEachDimensionBecomesItsOwnMacro: + def test_the_volume_envelope_becomes_the_level_a_tick_takes(self) -> None: envelopes = features_to_envelopes( build_features(VOLUME_ENVELOPE, arpeggio=PITCH_CONTOUR), ChannelName.PULSE1, ) - assert [row.volume_or_rate for row in envelopes.rows] == VOLUME_ENVELOPE + assert volume_values(envelopes) == VOLUME_ENVELOPE def test_the_contour_becomes_the_table(self) -> None: envelopes = features_to_envelopes( @@ -66,14 +74,22 @@ def test_the_duty_item_reaches_the_field_its_channel_reads(self, case: PulseWidt build_features([15], duty_cycle=[case.duty_cycle]), case.channel, ) - assert envelopes.rows[0].pulse_width == case.pulse_width + assert waveform_values(envelopes) == [case.pulse_width] - def test_a_channel_without_a_duty_envelope_plays_one_waveform(self) -> None: + def test_the_triangle_channel_leaves_its_one_waveform_alone(self) -> None: + """The triangle plays a single waveform, so the field it would name stays at its default.""" envelopes = features_to_envelopes( - build_features(VOLUME_ENVELOPE), + build_features(VOLUME_ENVELOPE, duty_cycle=[3] * len(VOLUME_ENVELOPE)), ChannelName.TRIANGLE, ) - assert {row.pulse_width for row in envelopes.rows} == {FLAT_PULSE_WIDTH} + assert set(envelopes.macros) == {NesMacroField.VOLUME_OR_RATE} + + def test_a_channel_without_a_duty_envelope_holds_one_waveform(self) -> None: + envelopes = features_to_envelopes( + build_features(VOLUME_ENVELOPE), + ChannelName.PULSE1, + ) + assert waveform_values(envelopes) == [FLAT_PULSE_WIDTH] def test_a_noise_contour_takes_the_offsets_that_move_its_period(self) -> None: steps = [0, 1, -1, 5] @@ -84,88 +100,82 @@ def test_a_noise_contour_takes_the_offsets_that_move_its_period(self) -> None: assert list(envelopes.table_rows) == [(-step) % NUM_PERIODS for step in steps] -class TestTheDimensionsStayInStep: - """Instrument rows and table rows advance on their own per-tick counters, so a - length they share is what keeps the volume envelope aligned with the pitch contour. +class TestEachDimensionKeepsItsOwnLength: + """Bitphase advances every field on a counter of its own, so a dimension holding one + value all through costs that one value however long the others run. """ - @pytest.mark.parametrize("loop", [True, False], ids=["looping", "one_shot"]) - def test_the_rows_and_the_table_share_a_length(self, loop: bool) -> None: + def test_a_dimension_holds_the_length_it_was_written_at(self) -> None: envelopes = features_to_envelopes( - looping(build_features(VOLUME_ENVELOPE, arpeggio=PITCH_CONTOUR[:3]), 0 if loop else None), + build_features(VOLUME_ENVELOPE, arpeggio=PITCH_CONTOUR[:2]), ChannelName.PULSE1, ) - assert len(envelopes.rows) == len(envelopes.table_rows) + assert len(volume_values(envelopes)) == len(VOLUME_ENVELOPE) + assert len(envelopes.table_rows) == 2 - def test_a_looping_slice_stands_at_the_longest_dimension(self) -> None: - """Each dimension keeps its own length, so circling costs a slice none of its rows.""" + def test_a_slice_without_a_contour_holds_one_step(self) -> None: envelopes = features_to_envelopes( - looping(build_features(VOLUME_ENVELOPE, arpeggio=PITCH_CONTOUR[:2]), 0), + build_features(VOLUME_ENVELOPE, arpeggio=[]), ChannelName.PULSE1, ) - assert len(envelopes.rows) == len(VOLUME_ENVELOPE) + assert list(envelopes.table_rows) == [NO_TABLE_OFFSET] - def test_a_one_shot_holds_the_shorter_dimension_to_the_end(self) -> None: + def test_the_instrument_runs_as_long_as_its_longest_dimension(self) -> None: envelopes = features_to_envelopes( build_features(VOLUME_ENVELOPE, arpeggio=PITCH_CONTOUR[:2]), ChannelName.PULSE1, ) - assert list(envelopes.table_rows) == [0, 2, 2, 2, 2] - - def test_a_slice_without_a_contour_holds_its_note(self) -> None: - envelopes = features_to_envelopes( - build_features(VOLUME_ENVELOPE, arpeggio=[]), - ChannelName.PULSE1, - ) - assert list(envelopes.table_rows) == [NO_TABLE_OFFSET] * len(VOLUME_ENVELOPE) + assert envelopes.ticks == len(VOLUME_ENVELOPE) -class TestTheLoopPoint: - def test_a_looping_slice_returns_to_its_first_row(self) -> None: +class TestThePointADimensionRepeatsFrom: + def test_a_looping_dimension_states_the_point_it_circles_from(self) -> None: envelopes = features_to_envelopes( - looping(build_features(VOLUME_ENVELOPE), 0), + looping(build_features(VOLUME_ENVELOPE, arpeggio=PITCH_CONTOUR), 0), ChannelName.PULSE1, ) - assert envelopes.loop == LOOP_FROM_START + assert envelopes.macros[NesMacroField.VOLUME_OR_RATE].loop == LOOP_FROM_START + assert envelopes.table_loop == LOOP_FROM_START - def test_a_one_shot_rests_on_its_last_row(self) -> None: + def test_a_one_shot_holds_its_final_value(self) -> None: + """Circling from the last value is what holds it, since playback never halts.""" envelopes = features_to_envelopes( - build_features(VOLUME_ENVELOPE), + build_features(VOLUME_ENVELOPE, arpeggio=PITCH_CONTOUR), ChannelName.PULSE1, ) - assert envelopes.loop == len(envelopes.rows) - 1 + assert envelopes.macros[NesMacroField.VOLUME_OR_RATE].loop == len(VOLUME_ENVELOPE) - 1 + assert envelopes.table_loop == len(PITCH_CONTOUR) - 1 def test_a_one_shot_rests_in_silence(self) -> None: - """Playback always returns to the loop row, so a slice that has played through - rests on the note-off item its volume envelope ends with. - """ + """A slice that has played through rests on the note-off item its volume ends with.""" envelopes = features_to_envelopes( build_features(VOLUME_ENVELOPE), ChannelName.PULSE1, ) - assert envelopes.rows[envelopes.loop].volume_or_rate == SILENT_VOLUME + macro = envelopes.macros[NesMacroField.VOLUME_OR_RATE] + assert macro.values[macro.loop] == SILENT_VOLUME @pytest.mark.parametrize("loop", [True, False], ids=["looping", "one_shot"]) - def test_the_loop_row_exists_in_both_lists(self, loop: bool) -> None: + def test_every_point_stands_among_the_values_it_circles(self, loop: bool) -> None: envelopes = features_to_envelopes( looping(build_features(VOLUME_ENVELOPE, arpeggio=PITCH_CONTOUR), 0 if loop else None), ChannelName.PULSE1, ) - assert envelopes.loop < len(envelopes.rows) - assert envelopes.loop < len(envelopes.table_rows) + assert all(macro.loop < len(macro.values) for macro in envelopes.macros.values()) + assert envelopes.table_loop < len(envelopes.table_rows) class TestASliceThatLeavesItsVolumeToTheChannel: """An instrument with no volume envelope sounds at the level its channel carries, so - every frame it describes reaches Bitphase as a full-level row. + one full level is what it writes. """ - def test_it_holds_a_full_row_per_frame(self) -> None: + def test_it_holds_one_full_level(self) -> None: envelopes = features_to_envelopes( build_features([], arpeggio=PITCH_CONTOUR), ChannelName.PULSE1, ) - assert [row.volume_or_rate for row in envelopes.rows] == [MAX_VOLUME_OR_RATE] * len(PITCH_CONTOUR) + assert volume_values(envelopes) == [MAX_VOLUME_OR_RATE] def test_its_contour_still_moves_the_note(self) -> None: envelopes = features_to_envelopes( @@ -174,32 +184,41 @@ def test_its_contour_still_moves_the_note(self) -> None: ) assert list(envelopes.table_rows) == PITCH_CONTOUR - def test_its_duty_envelope_still_reaches_the_rows(self) -> None: + def test_its_duty_envelope_still_reaches_the_waveform(self) -> None: duty_cycles = [0, 1, 2, 3] envelopes = features_to_envelopes( build_features([], duty_cycle=duty_cycles), ChannelName.PULSE1, ) - assert [row.pulse_width for row in envelopes.rows] == duty_cycles + assert waveform_values(envelopes) == duty_cycles + - def test_a_one_shot_rests_at_the_level_the_channel_holds(self) -> None: +class TestADimensionPastWhatAMacroStores: + def test_it_keeps_its_opening_values(self) -> None: + contour = list(range(MAX_MACRO_LENGTH + 8)) envelopes = features_to_envelopes( - build_features([], arpeggio=PITCH_CONTOUR), + build_features([MAX_VOLUME_OR_RATE] * (MAX_MACRO_LENGTH + 8), arpeggio=contour), ChannelName.PULSE1, ) - assert envelopes.rows[envelopes.loop].volume_or_rate == MAX_VOLUME_OR_RATE + assert len(volume_values(envelopes)) == MAX_MACRO_LENGTH + + def test_a_volume_ending_in_silence_keeps_that_silence(self) -> None: + """A shortened volume that dropped its note-off would sound on for as long as the note does.""" + volume = [MAX_VOLUME_OR_RATE] * (MAX_MACRO_LENGTH + 8) + [SILENT_VOLUME] + envelopes = features_to_envelopes(build_features(volume), ChannelName.PULSE1) + assert volume_values(envelopes)[-1] == SILENT_VOLUME - def test_a_looping_slice_takes_the_length_its_contour_states(self) -> None: + def test_the_table_carries_a_contour_of_any_length(self) -> None: + contour = list(range(MAX_MACRO_LENGTH + 8)) envelopes = features_to_envelopes( - looping(build_features([], arpeggio=PITCH_CONTOUR), 0), + build_features([MAX_VOLUME_OR_RATE], arpeggio=contour), ChannelName.PULSE1, ) - assert len(envelopes.rows) == len(PITCH_CONTOUR) - assert envelopes.loop == LOOP_FROM_START + assert len(envelopes.table_rows) == len(contour) class TestAnEmptySlice: - """An instrument holds at least one row, so a slice with no volume envelope still + """An instrument holds at least one value, so a slice with no volume envelope still reaches Bitphase as a playable silent instrument. """ @@ -207,11 +226,12 @@ class TestAnEmptySlice: def envelopes_fixture(self) -> ChannelEnvelopes: return features_to_envelopes(build_features([]), ChannelName.PULSE1) - def test_it_holds_one_silent_row(self, envelopes: ChannelEnvelopes) -> None: - assert [row.volume_or_rate for row in envelopes.rows] == [SILENT_VOLUME] + def test_it_holds_one_silent_level(self, envelopes: ChannelEnvelopes) -> None: + assert volume_values(envelopes) == [SILENT_VOLUME] def test_its_table_holds_one_flat_offset(self, envelopes: ChannelEnvelopes) -> None: assert envelopes.table_rows == (NO_TABLE_OFFSET,) - def test_it_loops_on_that_row(self, envelopes: ChannelEnvelopes) -> None: - assert envelopes.loop == LOOP_FROM_START + def test_it_circles_that_value(self, envelopes: ChannelEnvelopes) -> None: + assert envelopes.macros[NesMacroField.VOLUME_OR_RATE].loop == LOOP_FROM_START + assert envelopes.table_loop == LOOP_FROM_START diff --git a/tests/unit/sampletones_core/formats/bitphase/test_instrument_document.py b/tests/unit/sampletones_core/formats/bitphase/test_instrument_document.py index 61dbfd66b..3123a7800 100644 --- a/tests/unit/sampletones_core/formats/bitphase/test_instrument_document.py +++ b/tests/unit/sampletones_core/formats/bitphase/test_instrument_document.py @@ -4,6 +4,7 @@ from sampletones_core.features.envelope import Envelope from sampletones_core.formats.bitphase.builder import project_to_bitphase from sampletones_core.formats.bitphase.specification.instruments import LOOP_FROM_START +from sampletones_core.formats.bitphase.specification.macros import NesMacroField from sampletones_core.project.patterns.row import Row from sampletones_core.project.project import Project from sampletones_core.project.voices.envelopes import InstrumentEnvelopes @@ -48,21 +49,28 @@ def test_every_instrument_runs_the_ticks_its_envelopes_describe(self) -> None: document = project_to_bitphase(project) - assert all(len(instrument.rows) == len(VOLUME) for instrument in document.instruments) + assert all( + len(instrument.macros[NesMacroField.VOLUME_OR_RATE].values) == len(VOLUME) + for instrument in document.instruments + ) def test_a_looping_instrument_returns_to_its_loop_point(self) -> None: project, _ = _project(ChannelName.PULSE1, loop_point=1) document = project_to_bitphase(project) - assert all(instrument.loop == 1 for instrument in document.instruments) + assert all(instrument.macros[NesMacroField.VOLUME_OR_RATE].loop == 1 for instrument in document.instruments) - def test_a_one_shot_rests_on_its_final_row(self) -> None: + def test_a_one_shot_holds_its_final_value(self) -> None: project, _ = _project(ChannelName.PULSE1) document = project_to_bitphase(project) - assert all(instrument.loop == len(instrument.rows) - 1 for instrument in document.instruments) + assert all( + macro.loop == len(macro.values) - 1 + for instrument in document.instruments + for macro in instrument.macros.values() + ) def test_the_table_carries_the_instruments_contour(self) -> None: project, _ = _project(ChannelName.PULSE1) @@ -71,9 +79,13 @@ def test_the_table_carries_the_instruments_contour(self) -> None: assert any(tuple(table.rows) == ARPEGGIO for table in document.tables) - def test_the_document_is_written_without_a_loop_past_its_rows(self) -> None: + def test_every_point_stands_among_the_values_it_circles(self) -> None: project, _ = _project(ChannelName.PULSE1, loop_point=LOOP_FROM_START) document = project_to_bitphase(project) - assert all(instrument.loop < len(instrument.rows) for instrument in document.instruments) + assert all( + macro.loop < len(macro.values) + for instrument in document.instruments + for macro in instrument.macros.values() + ) diff --git a/tests/unit/sampletones_core/formats/bitphase/test_preset.py b/tests/unit/sampletones_core/formats/bitphase/test_preset.py index d99ed9729..21da233b6 100644 --- a/tests/unit/sampletones_core/formats/bitphase/test_preset.py +++ b/tests/unit/sampletones_core/formats/bitphase/test_preset.py @@ -19,6 +19,7 @@ MIN_TONE_ADD, NO_TONE_OFFSET, ) +from sampletones_core.formats.bitphase.specification.macros import NesMacroField from sampletones_shared.paths.extensions import EXT_FILE_JSON from .conftest import REFERENCE_PITCH, build_features, build_instrument @@ -26,7 +27,15 @@ VOLUME_ENVELOPE: Final[List[int]] = [15, 10, 5, 0] PITCH_CONTOUR: Final[List[int]] = [0, 3, 7, 12] NOISE_PERIOD: Final[int] = 4 -PRESET_KEYS: Final[List[str]] = ["chipType", "name", "loop", "rows"] +PRESET_KEYS: Final[List[str]] = ["chipType", "name", "macros"] + + +def levels(preset: BitphaseInstrumentPreset) -> List[int]: + return list(preset.macros[NesMacroField.VOLUME_OR_RATE].values) + + +def offsets(preset: BitphaseInstrumentPreset) -> List[int]: + return list(preset.macros[NesMacroField.TONE_ADD].values) @pytest.fixture(name="preset") @@ -47,45 +56,45 @@ class TestThePresetCarriesTheSlice: def test_it_takes_the_slice_name(self, preset: BitphaseInstrumentPreset) -> None: assert preset.name == "Lead" - def test_it_holds_one_row_per_envelope_item(self, preset: BitphaseInstrumentPreset) -> None: - assert [row.volume_or_rate for row in preset.rows] == VOLUME_ENVELOPE + def test_it_holds_one_level_per_envelope_item(self, preset: BitphaseInstrumentPreset) -> None: + assert levels(preset) == VOLUME_ENVELOPE - def test_a_one_shot_rests_on_its_last_row(self, preset: BitphaseInstrumentPreset) -> None: - assert preset.loop == len(preset.rows) - 1 + def test_a_one_shot_holds_its_final_value(self, preset: BitphaseInstrumentPreset) -> None: + assert all(macro.loop == len(macro.values) - 1 for macro in preset.macros.values()) - def test_a_looping_slice_returns_to_its_first_row(self) -> None: + def test_a_looping_slice_circles_from_its_first_value(self) -> None: preset = instrument_to_preset( build_instrument("Pad", build_features(VOLUME_ENVELOPE), loop_point=0), ) - assert preset.loop == LOOP_FROM_START + assert preset.macros[NesMacroField.VOLUME_OR_RATE].loop == LOOP_FROM_START class TestThePitchContourRidesInTheToneOffset: - """A preset carries rows alone, so the movement a table would drive is expressed as - the per-tick period offset each row adds to the note's own period. + """A preset carries macros alone, so the movement a table would drive is expressed as + the per-tick period offset each tick adds to the note's own period. """ - def test_each_row_offsets_the_period_its_semitone_asks_for(self, preset: BitphaseInstrumentPreset) -> None: + def test_each_tick_offsets_the_period_its_semitone_asks_for(self, preset: BitphaseInstrumentPreset) -> None: base_index = pitch_to_note_index(REFERENCE_PITCH) base_period = PRESET_TUNING_TABLE[base_index] expected = [PRESET_TUNING_TABLE[base_index + semitones] - base_period for semitones in PITCH_CONTOUR] - assert [row.tone_add for row in preset.rows] == expected + assert offsets(preset) == expected - def test_the_first_row_plays_the_reconstructed_pitch(self, preset: BitphaseInstrumentPreset) -> None: - assert preset.rows[0].tone_add == NO_TONE_OFFSET + def test_the_first_tick_plays_the_reconstructed_pitch(self, preset: BitphaseInstrumentPreset) -> None: + assert offsets(preset)[0] == NO_TONE_OFFSET def test_a_rising_contour_shortens_the_period(self, preset: BitphaseInstrumentPreset) -> None: - offsets = [row.tone_add for row in preset.rows] - assert all(later <= earlier for earlier, later in zip(offsets, offsets[1:])) + written = offsets(preset) + assert all(later <= earlier for earlier, later in zip(written, written[1:])) def test_every_offset_fits_the_field(self, preset: BitphaseInstrumentPreset) -> None: - assert all(MIN_TONE_ADD <= row.tone_add <= MAX_TONE_ADD for row in preset.rows) + assert all(MIN_TONE_ADD <= offset <= MAX_TONE_ADD for offset in offsets(preset)) def test_a_contour_reaching_past_the_tuning_table_holds_its_edge(self) -> None: preset = instrument_to_preset( build_instrument("Sweep", build_features(VOLUME_ENVELOPE, arpeggio=[0, 40, 80, 120])), ) - assert all(MIN_TONE_ADD <= row.tone_add <= MAX_TONE_ADD for row in preset.rows) + assert all(MIN_TONE_ADD <= offset <= MAX_TONE_ADD for offset in offsets(preset)) def test_a_noise_slice_takes_its_period_from_the_note(self) -> None: preset = instrument_to_preset( @@ -95,7 +104,7 @@ def test_a_noise_slice_takes_its_period_from_the_note(self) -> None: channel=ChannelName.NOISE, ), ) - assert {row.tone_add for row in preset.rows} == {NO_TONE_OFFSET} + assert set(offsets(preset)) == {NO_TONE_OFFSET} class TestThePresetFile: @@ -103,7 +112,7 @@ class TestThePresetFile: def test_it_holds_every_field_the_panel_reads(self, document: Dict[str, Any], key: str) -> None: assert key in document - def test_it_names_the_chip_whose_rows_it_holds(self, document: Dict[str, Any]) -> None: + def test_it_names_the_chip_whose_fields_it_holds(self, document: Dict[str, Any]) -> None: assert document["chipType"] == CHIP_TYPE_NES def test_it_is_indented_the_way_bitphase_writes_its_own( @@ -113,5 +122,8 @@ def test_it_is_indented_the_way_bitphase_writes_its_own( write_preset(destination, preset) assert '\n "name"' in destination.read_text(encoding="utf-8") - def test_its_rows_carry_the_field_names_the_panel_reads(self, document: Dict[str, Any]) -> None: - assert {"pulseWidth", "volumeOrRate", "toneAdd"} <= set(document["rows"][0]) + def test_its_macros_carry_the_field_names_the_panel_reads(self, document: Dict[str, Any]) -> None: + assert {"pulseWidth", "volumeOrRate", "toneAdd"} <= set(document["macros"]) + + def test_each_macro_carries_its_values_and_the_point_they_circle_from(self, document: Dict[str, Any]) -> None: + assert all(set(macro) == {"values", "loop"} for macro in document["macros"].values()) From 571906f332c0eff5158425ac559053bb41481fa8 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 20 Sep 2026 02:13:54 +0200 Subject: [PATCH 4/7] Added: the bend a Bitphase export sounds --- docs/development/bugs-and-todos.md | 3 - src/sampletones_core/exporters/bend.py | 48 +++++++ .../formats/bitphase/envelopes.py | 63 ++++++++- .../formats/bitphase/pitch.py | 39 +++++ .../formats/bitphase/preset.py | 73 ++++++---- .../formats/bitphase/tuning.py | 10 +- .../sampletones_core/exporters/test_bend.py | 57 ++++++++ .../formats/bitphase/conftest.py | 6 +- .../formats/bitphase/test_bends.py | 133 ++++++++++++++++++ .../formats/bitphase/test_preset.py | 11 +- 10 files changed, 392 insertions(+), 51 deletions(-) create mode 100644 src/sampletones_core/exporters/bend.py create mode 100644 src/sampletones_core/formats/bitphase/pitch.py create mode 100644 tests/unit/sampletones_core/exporters/test_bend.py create mode 100644 tests/unit/sampletones_core/formats/bitphase/test_bends.py diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 5e20e6037..1e7ccaa36 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -29,9 +29,6 @@ starts carrying. one. * Arpeggio modes: a sequence's `setting` byte states absolute. Fixed, relative and scheme need an enum of their own, and scheme needs the item bit-packing FamiTracker gives it. -* The bend in a Bitphase export. `formats/bitphase/envelopes.py` states the three dimensions it - writes; `NesInstrumentRow` already carries `tone_add` and `tone_accumulation`, so the mapping is - confined to that module. * A Bitphase document states concert pitch whatever the reconstruction was tuned at: `_build_song` builds the tuning table from `DEFAULT_A4_TUNING` and leaves `request.tuning` unread. * A transpose or a volume typed in the sample column of a row holding no sample reaches every diff --git a/src/sampletones_core/exporters/bend.py b/src/sampletones_core/exporters/bend.py new file mode 100644 index 000000000..5a02821d7 --- /dev/null +++ b/src/sampletones_core/exporters/bend.py @@ -0,0 +1,48 @@ +from typing import Optional + +from sampletones_core.features.envelope import Envelope +from sampletones_core.instructions.tonal import bend_steps + +NO_BEND_STEP = 0 + + +def bend_envelope(pitch: Optional[Envelope[int]], hi_pitch: Optional[Envelope[int]]) -> Envelope[int]: + """The timer steps a slice's two bend dimensions move its note by, one value per tick. + + A bend reaches a register as one divider whichever split of fine and coarse steps stated + it, and this is that reading taken from the envelopes — the dimension-side twin of + ``TonalExporter.read_timer_offsets``. Each dimension advances on a counter of its own, so + the pair states an offset for as long as the longer of them runs, and a dimension standing + past its end reads at the value it holds there. + + Args: + pitch: The dimension carrying one step per unit, where the channel offers it. + hi_pitch: The dimension carrying sixteen steps per unit, where the channel offers it. + + Returns: + Envelope[int]: The steps each tick stands away from its note, written where either + dimension carries items. + """ + fine = pitch if pitch is not None else Envelope[int]() + coarse = hi_pitch if hi_pitch is not None else Envelope[int]() + ticks = max(len(fine.items), len(coarse.items)) + if not ticks: + return Envelope[int]() + + items = tuple(bend_steps(_step(fine, tick), _step(coarse, tick)) for tick in range(ticks)) + return Envelope[int](items=items, loop_point=_repeat_point(fine, coarse)) + + +def _step(envelope: Envelope[int], tick: int) -> int: + """The value a dimension carries at a tick, which is nothing where it writes no items.""" + value = envelope.at(tick) + return NO_BEND_STEP if value is None else value + + +def _repeat_point(fine: Envelope[int], coarse: Envelope[int]) -> Optional[int]: + """The point the pair circles from, which is the earliest either dimension states.""" + points = [envelope.loop_point for envelope in (fine, coarse) if envelope.loop_point is not None] + if not points: + return None + + return min(points) diff --git a/src/sampletones_core/formats/bitphase/envelopes.py b/src/sampletones_core/formats/bitphase/envelopes.py index 80869845e..eed7fb633 100644 --- a/src/sampletones_core/formats/bitphase/envelopes.py +++ b/src/sampletones_core/formats/bitphase/envelopes.py @@ -2,11 +2,13 @@ from typing import Dict, Tuple from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.exporters.bend import bend_envelope from sampletones_core.exporters.feature import Features from sampletones_core.features.envelope import Envelope from sampletones_core.formats.bitphase.macros import macro, stored_envelope from sampletones_core.formats.bitphase.model.instrument import InstrumentMacro -from sampletones_core.formats.bitphase.notes import noise_arpeggio_to_table_offset +from sampletones_core.formats.bitphase.notes import noise_arpeggio_to_table_offset, pitch_to_note_index +from sampletones_core.formats.bitphase.pitch import contour_period, sounding_offset from sampletones_core.formats.bitphase.specification.instruments import ( FLAT_PULSE_WIDTH, LOOP_FROM_START, @@ -103,22 +105,68 @@ def _contour(features: Features, channel: ChannelName) -> Envelope[int]: def _silent_slice(features: Features, channel: ChannelName) -> ChannelEnvelopes: """The smallest instrument Bitphase plays, which is what a slice describing no frame writes.""" return ChannelEnvelopes( - macros=_macro_bag(SILENT_VOLUME_ENVELOPE, features, channel), + macros=_macro_bag(SILENT_VOLUME_ENVELOPE, features, channel, FLAT_CONTOUR), table_rows=FLAT_CONTOUR.items, table_loop=LOOP_FROM_START, ticks=0, ) -def _macro_bag(volume: Envelope[int], features: Features, channel: ChannelName) -> MacroBag: +def _macro_bag( + volume: Envelope[int], + features: Features, + channel: ChannelName, + contour: Envelope[int], +) -> MacroBag: """The fields the slice decides, the triangle channel leaving its one waveform alone.""" macros = {NesMacroField.VOLUME_OR_RATE: macro(volume)} if channel != ChannelName.TRIANGLE: macros[NesMacroField.PULSE_WIDTH] = macro(_waveform_envelope(features, channel)) + bend = _bend_envelope(features, channel, contour) + if bend.written: + macros[NesMacroField.TONE_ADD] = macro(bend) + return macros +def _bend_envelope(features: Features, channel: ChannelName, contour: Envelope[int]) -> Envelope[int]: + """The period offsets a slice's bend asks of each tick, measured from the note it sounds on. + + The table moves the note before Bitphase resolves its period, and the tone offset is added + to that period, so each tick is measured from the note its own contour step reaches. A slice + sounding every tick on its note writes no offset at all, and the noise channel takes its + period from the note, so its bend reaches playback through the note column. + + Args: + features: The per-dimension envelopes describing the slice. + channel: The NES channel the slice was reconstructed for. + contour: The semitone steps the table moves the note by. + + Returns: + Envelope[int]: The offset per tick, empty where the slice sounds on its note throughout. + """ + if channel == ChannelName.NOISE: + return Envelope[int]() + + bend = stored_envelope(FeatureKey.PITCH, bend_envelope(features.pitch, features.hi_pitch)) + if not any(bend.items): + return Envelope[int]() + + base_index = pitch_to_note_index(features.initial_pitch) + offsets = tuple( + sounding_offset(contour_period(base_index, _step(contour, tick)), steps) + for tick, steps in enumerate(bend.items) + ) + return bend.with_items(offsets) + + +def _step(contour: Envelope[int], tick: int) -> int: + """The semitone step the contour stands at on a tick, which is flat where it states none.""" + step = contour.at(tick) + return NO_TABLE_OFFSET if step is None else step + + def features_to_envelopes( features: Features, channel: ChannelName, @@ -126,9 +174,10 @@ def features_to_envelopes( """Converts one channel slice's envelopes into the macros and table Bitphase reads it from. Volume becomes the instrument's per-tick level, the duty cycle becomes the channel's - waveform field, and the arpeggio becomes the table contour that moves the note. Each - keeps the length and the repeat point it was written at, so a dimension holding one - value all through costs that one value. + waveform field, the arpeggio becomes the table contour that moves the note, and a bend + becomes the offset each tick adds to the period that note resolves to. Each keeps the + length and the repeat point it was written at, so a dimension holding one value all + through costs that one value. A slice describing no frame comes back as the one silent value that is the smallest instrument Bitphase plays. @@ -144,7 +193,7 @@ def features_to_envelopes( return _silent_slice(features, channel) contour = _contour(features, channel) - macros = _macro_bag(_volume_envelope(features), features, channel) + macros = _macro_bag(_volume_envelope(features), features, channel, contour) return ChannelEnvelopes( macros=macros, diff --git a/src/sampletones_core/formats/bitphase/pitch.py b/src/sampletones_core/formats/bitphase/pitch.py new file mode 100644 index 000000000..677519e88 --- /dev/null +++ b/src/sampletones_core/formats/bitphase/pitch.py @@ -0,0 +1,39 @@ +from sampletones_core.formats.bitphase.specification.chip import ( + MAX_TUNING_PERIOD, + MIN_TUNING_PERIOD, +) +from sampletones_core.formats.bitphase.specification.instruments import MAX_TONE_ADD, MIN_TONE_ADD +from sampletones_core.formats.bitphase.specification.patterns import MAX_NOTE_INDEX, MIN_NOTE_INDEX +from sampletones_core.formats.bitphase.tuning import DEFAULT_TUNING_TABLE + + +def contour_period(base_index: int, step: int) -> int: + """The period the note a contour step moves to sounds at. + + Args: + base_index: Note index the slice was reconstructed at. + step: Semitones the contour moves the note by. + + Returns: + int: The period of the note the step reaches, held inside the tuning table. + """ + index = min(max(base_index + step, MIN_NOTE_INDEX), MAX_NOTE_INDEX) + return DEFAULT_TUNING_TABLE[index] + + +def sounding_offset(base_period: int, offset: int) -> int: + """The tone offset that bends a period by as much as the channel goes on sounding. + + Bitphase adds the offset to the period its note resolves to, and a channel sounds while + that sum stands within the timer's range, so the offset is held to what keeps it there. + This is the rule ``bent_timer`` states for a divider, read in the periods Bitphase counts. + + Args: + base_period: The period the tick's note resolves to. + offset: The timer steps the tick stands away from that note. + + Returns: + int: The offset to write, within both the timer's range and the field's own. + """ + period = min(max(base_period + offset, MIN_TUNING_PERIOD), MAX_TUNING_PERIOD) + return min(max(period - base_period, MIN_TONE_ADD), MAX_TONE_ADD) diff --git a/src/sampletones_core/formats/bitphase/preset.py b/src/sampletones_core/formats/bitphase/preset.py index 51fbb6673..168cb2d7e 100644 --- a/src/sampletones_core/formats/bitphase/preset.py +++ b/src/sampletones_core/formats/bitphase/preset.py @@ -2,54 +2,48 @@ from pathlib import Path from typing import Final, Sequence, Tuple -from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.exporters.bend import bend_envelope +from sampletones_core.exporters.feature import Features from sampletones_core.exports.request import InstrumentExport from sampletones_core.features.envelope import Envelope -from sampletones_core.formats.bitphase.envelopes import features_to_envelopes -from sampletones_core.formats.bitphase.macros import macro +from sampletones_core.formats.bitphase.envelopes import ChannelEnvelopes, features_to_envelopes +from sampletones_core.formats.bitphase.macros import macro, stored_envelope from sampletones_core.formats.bitphase.model.instrument import BitphaseInstrumentPreset from sampletones_core.formats.bitphase.notes import pitch_to_note_index -from sampletones_core.formats.bitphase.specification.chip import DEFAULT_A4_TUNING, DEFAULT_CPU_FREQUENCY -from sampletones_core.formats.bitphase.specification.instruments import ( - MAX_TONE_ADD, - MIN_TONE_ADD, - NO_TONE_OFFSET, -) +from sampletones_core.formats.bitphase.pitch import contour_period, sounding_offset +from sampletones_core.formats.bitphase.specification.instruments import NO_TONE_OFFSET from sampletones_core.formats.bitphase.specification.macros import NesMacroField -from sampletones_core.formats.bitphase.specification.patterns import MAX_NOTE_INDEX, MIN_NOTE_INDEX -from sampletones_core.formats.bitphase.tuning import generate_tuning_table +from sampletones_core.formats.bitphase.tuning import DEFAULT_TUNING_TABLE -PRESET_TUNING_TABLE: Final[Tuple[int, ...]] = generate_tuning_table( - DEFAULT_CPU_FREQUENCY, - a4_tuning=DEFAULT_A4_TUNING, -) PRESET_JSON_INDENT: Final[int] = 2 def _tone_offsets( + features: Features, channel: ChannelName, - initial_pitch: int, contour: Sequence[int], ) -> Tuple[int, ...]: - """Expresses a semitone contour as the per-tick period offsets a preset carries. - - A preset holds macros alone, so its pitch movement rides in the tone offset each tick takes. - The offsets are measured against the pitch the slice was reconstructed at, under the - tuning the NTSC system gives at concert pitch, which is what a freshly created - Bitphase document plays. The noise channel takes its period from the note rather - than from a period offset, so its offsets stay flat and the note carries the pitch. + """Expresses a slice's pitch movement as the per-tick period offsets a preset carries. + + A preset holds macros alone, so the movement a table would drive rides in the tone offset + each tick takes, beside the bend the slice sounds. The offsets are measured against the + pitch the slice was reconstructed at, under the tuning the NTSC system gives at concert + pitch, which is what a freshly created Bitphase document plays. The noise channel takes its + period from the note rather than from a period offset, so its offsets stay flat and the note + carries the pitch. """ if channel == ChannelName.NOISE: return (NO_TONE_OFFSET,) * len(contour) - base_index = pitch_to_note_index(initial_pitch) - base_period = PRESET_TUNING_TABLE[base_index] + base_index = pitch_to_note_index(features.initial_pitch) + base_period = DEFAULT_TUNING_TABLE[base_index] + bend = stored_envelope(FeatureKey.PITCH, bend_envelope(features.pitch, features.hi_pitch)) offsets = [] - for semitones in contour: - index = max(MIN_NOTE_INDEX, min(MAX_NOTE_INDEX, base_index + semitones)) - offset = PRESET_TUNING_TABLE[index] - base_period - offsets.append(max(MIN_TONE_ADD, min(MAX_TONE_ADD, offset))) + for tick, semitones in enumerate(contour): + moved = contour_period(base_index, semitones) - base_period + offsets.append(sounding_offset(base_period, moved + _value(bend, tick))) return tuple(offsets) @@ -68,9 +62,9 @@ def instrument_to_preset(request: InstrumentExport) -> BitphaseInstrumentPreset: request.channel, ) offsets = _tone_offsets( + request.features, request.channel, - request.features.initial_pitch, - envelopes.table_rows, + _contour(envelopes), ) return BitphaseInstrumentPreset( @@ -82,6 +76,23 @@ def instrument_to_preset(request: InstrumentExport) -> BitphaseInstrumentPreset: ) +def _contour(envelopes: ChannelEnvelopes) -> Tuple[int, ...]: + """The semitone steps a preset states an offset for, one per tick the instrument runs. + + A document moves the note through a table that advances on a counter of its own, where a + preset carries the movement in the offsets themselves, so the contour is read out to the + ticks the slice describes. + """ + contour = Envelope[int](items=envelopes.table_rows, loop_point=envelopes.table_loop) + return tuple(_value(contour, tick) for tick in range(envelopes.ticks)) + + +def _value(envelope: Envelope[int], tick: int) -> int: + """The value a dimension stands at on a tick, which is nothing where it writes none.""" + value = envelope.at(tick) + return NO_TONE_OFFSET if value is None else value + + def write_preset(destination: Path, preset: BitphaseInstrumentPreset) -> None: """Writes a Bitphase instrument preset to disk. diff --git a/src/sampletones_core/formats/bitphase/tuning.py b/src/sampletones_core/formats/bitphase/tuning.py index 7c907759d..7db20842a 100644 --- a/src/sampletones_core/formats/bitphase/tuning.py +++ b/src/sampletones_core/formats/bitphase/tuning.py @@ -1,7 +1,9 @@ import math -from typing import Tuple +from typing import Final, Tuple from sampletones_core.formats.bitphase.specification.chip import ( + DEFAULT_A4_TUNING, + DEFAULT_CPU_FREQUENCY, MAX_TUNING_PERIOD, MIN_TUNING_PERIOD, TUNING_A4_INDEX, @@ -39,3 +41,9 @@ def generate_tuning_table( periods.append(max(MIN_TUNING_PERIOD, min(max_period, period))) return tuple(periods) + + +DEFAULT_TUNING_TABLE: Final[Tuple[int, ...]] = generate_tuning_table( + DEFAULT_CPU_FREQUENCY, + a4_tuning=DEFAULT_A4_TUNING, +) diff --git a/tests/unit/sampletones_core/exporters/test_bend.py b/tests/unit/sampletones_core/exporters/test_bend.py new file mode 100644 index 000000000..976861081 --- /dev/null +++ b/tests/unit/sampletones_core/exporters/test_bend.py @@ -0,0 +1,57 @@ +from typing import Final, Optional, Sequence + +from sampletones_core.constants.general import HI_PITCH_FACTOR +from sampletones_core.exporters.bend import bend_envelope +from sampletones_core.features.envelope import Envelope + +FINE_STEPS: Final[Sequence[int]] = (0, -3, -7) +COARSE_STEPS: Final[Sequence[int]] = (0, 1, 2) + + +def envelope(items: Sequence[int], loop_point: Optional[int] = None) -> Envelope[int]: + return Envelope[int](items=tuple(items), loop_point=loop_point) + + +class TestTheOffsetTwoDimensionsStateTogether: + def test_a_fine_dimension_alone_carries_its_steps(self) -> None: + assert bend_envelope(envelope(FINE_STEPS), None).items == tuple(FINE_STEPS) + + def test_a_coarse_step_counts_sixteen(self) -> None: + assert bend_envelope(None, envelope(COARSE_STEPS)).items == tuple( + step * HI_PITCH_FACTOR for step in COARSE_STEPS + ) + + def test_both_dimensions_reach_the_same_tick(self) -> None: + combined = bend_envelope(envelope(FINE_STEPS), envelope(COARSE_STEPS)) + assert combined.items == tuple( + fine + coarse * HI_PITCH_FACTOR for fine, coarse in zip(FINE_STEPS, COARSE_STEPS) + ) + + def test_a_channel_offering_neither_dimension_states_nothing(self) -> None: + assert bend_envelope(None, None).items == () + + def test_a_dimension_writing_no_items_states_nothing(self) -> None: + assert bend_envelope(envelope(()), envelope(())).items == () + + +class TestDimensionsOfDifferentLengths: + def test_the_pair_runs_as_long_as_the_longer_of_them(self) -> None: + combined = bend_envelope(envelope((1, 2, 3, 4)), envelope((1,))) + assert len(combined.items) == 4 + + def test_a_dimension_past_its_end_reads_at_the_value_it_holds(self) -> None: + combined = bend_envelope(envelope((0, 0, 0)), envelope((1,))) + assert combined.items == (HI_PITCH_FACTOR,) * 3 + + def test_a_circling_dimension_reads_at_the_value_it_comes_round_to(self) -> None: + combined = bend_envelope(envelope((1, 2), loop_point=0), envelope(())) + assert combined.items == (1, 2) + + +class TestThePointThePairCirclesFrom: + def test_a_pair_holding_its_last_value_states_no_point(self) -> None: + assert bend_envelope(envelope(FINE_STEPS), envelope(COARSE_STEPS)).loop_point is None + + def test_the_earliest_point_either_dimension_states_carries(self) -> None: + combined = bend_envelope(envelope((1, 2, 3), loop_point=2), envelope((0, 0, 0), loop_point=1)) + assert combined.loop_point == 1 diff --git a/tests/unit/sampletones_core/formats/bitphase/conftest.py b/tests/unit/sampletones_core/formats/bitphase/conftest.py index 8b8b36ba2..f86a26215 100644 --- a/tests/unit/sampletones_core/formats/bitphase/conftest.py +++ b/tests/unit/sampletones_core/formats/bitphase/conftest.py @@ -17,6 +17,8 @@ def build_features( *, arpeggio: Optional[Sequence[int]] = None, duty_cycle: Optional[Sequence[int]] = None, + bend: Optional[Sequence[int]] = None, + coarse_bend: Optional[Sequence[int]] = None, initial_pitch: int = REFERENCE_PITCH, ) -> Features: """Builds the envelopes of one channel slice, flat in every dimension left out.""" @@ -25,8 +27,8 @@ def build_features( initial_pitch=initial_pitch, volume=Envelope(items=tuple(volume)), arpeggio=Envelope(items=contour), - pitch=None, - hi_pitch=None, + pitch=None if bend is None else Envelope(items=tuple(bend)), + hi_pitch=None if coarse_bend is None else Envelope(items=tuple(coarse_bend)), duty_cycle=None if duty_cycle is None else Envelope(items=tuple(duty_cycle)), ) diff --git a/tests/unit/sampletones_core/formats/bitphase/test_bends.py b/tests/unit/sampletones_core/formats/bitphase/test_bends.py new file mode 100644 index 000000000..fb195af77 --- /dev/null +++ b/tests/unit/sampletones_core/formats/bitphase/test_bends.py @@ -0,0 +1,133 @@ +from typing import Final, List, Optional, Sequence + +import pytest + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.general import HI_PITCH_FACTOR +from sampletones_core.formats.bitphase.envelopes import features_to_envelopes +from sampletones_core.formats.bitphase.notes import pitch_to_note_index +from sampletones_core.formats.bitphase.preset import instrument_to_preset +from sampletones_core.formats.bitphase.specification.chip import ( + MAX_TUNING_PERIOD, + MIN_TUNING_PERIOD, +) +from sampletones_core.formats.bitphase.specification.instruments import ( + MAX_TONE_ADD, + MIN_TONE_ADD, + NO_TONE_OFFSET, +) +from sampletones_core.formats.bitphase.specification.macros import NesMacroField +from sampletones_core.formats.bitphase.tuning import DEFAULT_TUNING_TABLE + +from .conftest import REFERENCE_PITCH, build_features, build_instrument + +VOLUME_ENVELOPE: Final[List[int]] = [15, 12, 8, 4] +BEND_ENVELOPE: Final[List[int]] = [0, -3, -7, -12] +PITCH_CONTOUR: Final[List[int]] = [0, 5, 7, 12] +BASE_INDEX: Final[int] = pitch_to_note_index(REFERENCE_PITCH) +BASE_PERIOD: Final[int] = DEFAULT_TUNING_TABLE[BASE_INDEX] + + +def offsets( + channel: ChannelName = ChannelName.PULSE1, + *, + arpeggio: Optional[Sequence[int]] = None, + bend: Optional[Sequence[int]] = None, + coarse_bend: Optional[Sequence[int]] = None, +) -> List[int]: + features = build_features( + VOLUME_ENVELOPE, + arpeggio=arpeggio, + bend=bend, + coarse_bend=coarse_bend, + ) + macro = features_to_envelopes(features, channel).macros.get(NesMacroField.TONE_ADD) + return [] if macro is None else list(macro.values) + + +class TestTheBendReachesTheToneOffset: + def test_each_step_offsets_the_period_by_that_many(self) -> None: + assert offsets(bend=BEND_ENVELOPE) == BEND_ENVELOPE + + def test_a_coarse_step_counts_sixteen(self) -> None: + """The two dimensions state one bend together, at one step and at sixteen apiece.""" + assert offsets(bend=[1, 1], coarse_bend=[0, 2]) == [1, 1 + 2 * HI_PITCH_FACTOR] + + def test_a_slice_sounding_on_its_note_writes_no_offset(self) -> None: + assert offsets(bend=[0, 0, 0, 0]) == [] + + def test_a_slice_without_the_dimension_writes_no_offset(self) -> None: + assert offsets(arpeggio=PITCH_CONTOUR) == [] + + def test_the_triangle_channel_bends_its_period(self) -> None: + assert offsets(ChannelName.TRIANGLE, bend=BEND_ENVELOPE) == BEND_ENVELOPE + + def test_the_noise_channel_takes_its_period_from_the_note(self) -> None: + assert offsets(ChannelName.NOISE, bend=BEND_ENVELOPE) == [] + + def test_the_bend_circles_from_the_point_it_states(self) -> None: + written = features_to_envelopes( + build_features(VOLUME_ENVELOPE, bend=BEND_ENVELOPE), + ChannelName.PULSE1, + ) + assert written.macros[NesMacroField.TONE_ADD].loop == len(BEND_ENVELOPE) - 1 + + +class TestTheNoteABendIsMeasuredFrom: + """The table moves the note before Bitphase resolves its period, and the offset is added + to that period, so each tick is measured from the note its own contour step reaches. + """ + + def test_a_moved_note_keeps_the_steps_the_slice_states(self) -> None: + assert offsets(arpeggio=PITCH_CONTOUR, bend=BEND_ENVELOPE) == BEND_ENVELOPE + + def test_a_contour_holding_its_last_step_carries_the_bend_on(self) -> None: + assert offsets(arpeggio=[0, 12], bend=[-4, -4, -4, -4]) == [-4, -4, -4, -4] + + +class TestABendThePeriodRangeHolds: + @pytest.mark.parametrize("steps", [MIN_TONE_ADD, MAX_TONE_ADD], ids=["down", "up"]) + def test_the_period_it_reaches_stays_within_the_timer(self, steps: int) -> None: + written = offsets(bend=[steps] * len(VOLUME_ENVELOPE)) + assert all(MIN_TUNING_PERIOD <= BASE_PERIOD + offset <= MAX_TUNING_PERIOD for offset in written) + + def test_a_bend_past_the_shortest_period_stops_there(self) -> None: + assert offsets(bend=[-BASE_PERIOD - 100] * len(VOLUME_ENVELOPE))[0] == MIN_TUNING_PERIOD - BASE_PERIOD + + def test_every_offset_fits_the_field(self) -> None: + written = offsets(bend=[MAX_TONE_ADD] * len(VOLUME_ENVELOPE)) + assert all(MIN_TONE_ADD <= offset <= MAX_TONE_ADD for offset in written) + + +class TestAPresetCarriesBothMovements: + """A preset holds no table, so its tone offset carries the contour and the bend together.""" + + def test_its_offsets_add_the_bend_to_the_contour(self) -> None: + preset = instrument_to_preset( + build_instrument( + "Lead", + build_features(VOLUME_ENVELOPE, arpeggio=PITCH_CONTOUR, bend=BEND_ENVELOPE), + ), + ) + expected = [ + DEFAULT_TUNING_TABLE[BASE_INDEX + semitones] - BASE_PERIOD + steps + for semitones, steps in zip(PITCH_CONTOUR, BEND_ENVELOPE) + ] + assert list(preset.macros[NesMacroField.TONE_ADD].values) == expected + + def test_an_unbent_slice_carries_its_contour_alone(self) -> None: + preset = instrument_to_preset( + build_instrument("Pad", build_features(VOLUME_ENVELOPE, arpeggio=PITCH_CONTOUR)), + ) + expected = [DEFAULT_TUNING_TABLE[BASE_INDEX + semitones] - BASE_PERIOD for semitones in PITCH_CONTOUR] + assert list(preset.macros[NesMacroField.TONE_ADD].values) == expected + + def test_a_noise_slice_takes_its_period_from_the_note(self) -> None: + preset = instrument_to_preset( + build_instrument( + "Hat", + build_features(VOLUME_ENVELOPE, bend=BEND_ENVELOPE, initial_pitch=4), + channel=ChannelName.NOISE, + ), + ) + assert set(preset.macros[NesMacroField.TONE_ADD].values) == {NO_TONE_OFFSET} diff --git a/tests/unit/sampletones_core/formats/bitphase/test_preset.py b/tests/unit/sampletones_core/formats/bitphase/test_preset.py index 21da233b6..9a3f81b4b 100644 --- a/tests/unit/sampletones_core/formats/bitphase/test_preset.py +++ b/tests/unit/sampletones_core/formats/bitphase/test_preset.py @@ -7,11 +7,7 @@ from sampletones_core.constants.enums import ChannelName from sampletones_core.formats.bitphase.model.instrument import BitphaseInstrumentPreset from sampletones_core.formats.bitphase.notes import pitch_to_note_index -from sampletones_core.formats.bitphase.preset import ( - PRESET_TUNING_TABLE, - instrument_to_preset, - write_preset, -) +from sampletones_core.formats.bitphase.preset import instrument_to_preset, write_preset from sampletones_core.formats.bitphase.specification.chip import CHIP_TYPE_NES from sampletones_core.formats.bitphase.specification.instruments import ( LOOP_FROM_START, @@ -20,6 +16,7 @@ NO_TONE_OFFSET, ) from sampletones_core.formats.bitphase.specification.macros import NesMacroField +from sampletones_core.formats.bitphase.tuning import DEFAULT_TUNING_TABLE from sampletones_shared.paths.extensions import EXT_FILE_JSON from .conftest import REFERENCE_PITCH, build_features, build_instrument @@ -76,8 +73,8 @@ class TestThePitchContourRidesInTheToneOffset: def test_each_tick_offsets_the_period_its_semitone_asks_for(self, preset: BitphaseInstrumentPreset) -> None: base_index = pitch_to_note_index(REFERENCE_PITCH) - base_period = PRESET_TUNING_TABLE[base_index] - expected = [PRESET_TUNING_TABLE[base_index + semitones] - base_period for semitones in PITCH_CONTOUR] + base_period = DEFAULT_TUNING_TABLE[base_index] + expected = [DEFAULT_TUNING_TABLE[base_index + semitones] - base_period for semitones in PITCH_CONTOUR] assert offsets(preset) == expected def test_the_first_tick_plays_the_reconstructed_pitch(self, preset: BitphaseInstrumentPreset) -> None: From b1388190729ddd9e1f6155ef5cd0ce2801073ddf Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 20 Sep 2026 02:24:42 +0200 Subject: [PATCH 5/7] Added: the engine's own reading of a written Bitphase instrument --- tests/integration/bitphase/conftest.py | 12 ++ .../integration/bitphase/test_btp_pipeline.py | 6 - .../bitphase/test_playback_rules.py | 137 ++++++++++++++++++ tests/suite/bitphase.py | 74 ++++++++++ 4 files changed, 223 insertions(+), 6 deletions(-) create mode 100644 tests/integration/bitphase/test_playback_rules.py diff --git a/tests/integration/bitphase/conftest.py b/tests/integration/bitphase/conftest.py index 0007eda69..a8a978194 100644 --- a/tests/integration/bitphase/conftest.py +++ b/tests/integration/bitphase/conftest.py @@ -2,7 +2,12 @@ import pytest +from sampletones_core.formats.bitphase.btp import write_btp +from sampletones_core.formats.bitphase.builder import project_to_bitphase +from sampletones_core.formats.bitphase.specification.channels import CHANNEL_LABELS +from sampletones_core.project.project import Project from sampletones_tools.samples.bitphase import DOCUMENT_FILENAME, GROOVE_DOCUMENT_FILENAME +from tests.suite.bitphase import LoadedProject, parse_btp @pytest.fixture @@ -15,3 +20,10 @@ def document_path(tmp_path: Path) -> Path: def groove_document_path(tmp_path: Path) -> Path: """Where the document carrying a groove is written, beside the one at the song's own tempo.""" return tmp_path / GROOVE_DOCUMENT_FILENAME + + +@pytest.fixture +def document(integration_project: Project, document_path: Path) -> LoadedProject: + """The synthetic project written as a document and read back the way Bitphase loads it.""" + write_btp(document_path, project_to_bitphase(integration_project)) + return parse_btp(document_path.read_bytes(), list(CHANNEL_LABELS)) diff --git a/tests/integration/bitphase/test_btp_pipeline.py b/tests/integration/bitphase/test_btp_pipeline.py index 76a3de5b8..f0b485829 100644 --- a/tests/integration/bitphase/test_btp_pipeline.py +++ b/tests/integration/bitphase/test_btp_pipeline.py @@ -80,12 +80,6 @@ def note_index(note: LoadedNote) -> int: return note.name - int(NoteName.C) + (note.octave - FIRST_OCTAVE) * NOTE_RANGE -@pytest.fixture -def document(integration_project: Project, document_path: Path) -> LoadedProject: - write_btp(document_path, project_to_bitphase(integration_project)) - return parse_btp(document_path.read_bytes(), list(CHANNEL_LABELS)) - - @pytest.fixture def groove_document(integration_project: Project, groove_document_path: Path) -> LoadedProject: project = at_tempo(integration_project, GROOVE_TEMPO) diff --git a/tests/integration/bitphase/test_playback_rules.py b/tests/integration/bitphase/test_playback_rules.py new file mode 100644 index 000000000..ee407973c --- /dev/null +++ b/tests/integration/bitphase/test_playback_rules.py @@ -0,0 +1,137 @@ +from pathlib import Path +from typing import Final, List, Tuple + +import pytest + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.general import HI_PITCH_FACTOR +from sampletones_core.exporters.feature import Features +from sampletones_core.exports.request import InstrumentExport, SampleExport +from sampletones_core.features.envelope import Envelope +from sampletones_core.formats.bitphase.btp import write_btp +from sampletones_core.formats.bitphase.builder import sample_to_bitphase +from sampletones_core.formats.bitphase.notes import pitch_to_note_index +from sampletones_core.formats.bitphase.specification.channels import CHANNEL_LABELS +from sampletones_core.formats.bitphase.specification.chip import ( + MAX_TUNING_PERIOD, + MIN_TUNING_PERIOD, +) +from sampletones_core.formats.bitphase.specification.instruments import ( + MAX_VOLUME_OR_RATE, + MIN_VOLUME_OR_RATE, +) +from sampletones_core.formats.bitphase.specification.macros import MAX_MACRO_LENGTH +from sampletones_core.timers.arithmetic import bent_timer +from sampletones_core.timers.utils import get_timer_table +from sampletones_shared.music import Tuning +from tests.suite.bitphase import ( + LoadedInstrument, + LoadedProject, + LoadedTable, + parse_btp, + sounded_period, +) + +NES_FREQUENCY: Final[int] = 60 +REFERENCE_PITCH: Final[int] = 60 +VOLUME_ENVELOPE: Final[Tuple[int, ...]] = (15, 12, 9, 6, 3, 0) +PITCH_CONTOUR: Final[Tuple[int, ...]] = (0, 0, 5, 5, 7, 7) +BEND_STEPS: Final[Tuple[int, ...]] = (0, -4, -9, -15, -22, -30) +COARSE_STEPS: Final[Tuple[int, ...]] = (0, 0, 0, 1, 1, 2) +TICKS_SAMPLED: Final[int] = 64 +PERIOD_OVER_TIMER: Final[int] = 1 + + +def bent_slice(channel: ChannelName) -> InstrumentExport: + """One bent channel slice, moving its note by a contour and its period by a bend.""" + return InstrumentExport( + name=f"Bent ({channel})", + channel=channel, + features=Features( + initial_pitch=REFERENCE_PITCH, + volume=Envelope[int](items=VOLUME_ENVELOPE), + arpeggio=Envelope[int](items=PITCH_CONTOUR), + pitch=Envelope[int](items=BEND_STEPS), + hi_pitch=Envelope[int](items=COARSE_STEPS), + duty_cycle=None, + ), + nes_frequency=NES_FREQUENCY, + tuning=Tuning(), + ) + + +@pytest.fixture(name="bent_document") +def bent_document_fixture(tmp_path: Path) -> LoadedProject: + request = SampleExport( + name="Bent", + instruments=(bent_slice(ChannelName.PULSE1), bent_slice(ChannelName.TRIANGLE)), + nes_frequency=NES_FREQUENCY, + tuning=Tuning(), + ) + destination = tmp_path / "Bent.btp" + write_btp(destination, sample_to_bitphase(request)) + return parse_btp(destination.read_bytes(), list(CHANNEL_LABELS)) + + +def voices(document: LoadedProject) -> List[Tuple[LoadedInstrument, LoadedTable]]: + return list(zip(document.instruments, document.tables)) + + +class TestAPeriodTheEngineResolves: + """Bitphase reads a period out of three readings at once — the note the pattern names, the + step its table stands at, and the offset its instrument's tone macro holds — so the period + a tick sounds is what those three make of the document as written. + """ + + def test_every_tick_sounds_the_divider_the_reconstruction_renders( + self, + bent_document: LoadedProject, + ) -> None: + timers = get_timer_table(Tuning()) + table = bent_document.songs[0].tuning_table + note_index = pitch_to_note_index(REFERENCE_PITCH) + + for instrument, contour in voices(bent_document): + for tick, (step, steps, coarse) in enumerate(zip(PITCH_CONTOUR, BEND_STEPS, COARSE_STEPS)): + rendered = bent_timer(timers[REFERENCE_PITCH + step], steps + HI_PITCH_FACTOR * coarse) + sounded = sounded_period(table, note_index, instrument, contour, tick) + assert sounded == rendered + PERIOD_OVER_TIMER + + def test_a_channel_goes_on_sounding_for_as_long_as_the_note_does( + self, + bent_document: LoadedProject, + ) -> None: + """A period of zero silences a channel, so every tick resolves above it.""" + table = bent_document.songs[0].tuning_table + note_index = pitch_to_note_index(REFERENCE_PITCH) + + for instrument, contour in voices(bent_document): + periods = [sounded_period(table, note_index, instrument, contour, tick) for tick in range(TICKS_SAMPLED)] + assert all(MIN_TUNING_PERIOD <= period <= MAX_TUNING_PERIOD for period in periods) + + +class TestWhatEveryTickOfADocumentReads: + def test_every_level_sampled_is_one_the_channel_reads(self, document: LoadedProject) -> None: + levels = [ + instrument.value("volumeOrRate", tick) + for instrument in document.instruments + for tick in range(TICKS_SAMPLED) + ] + assert all(MIN_VOLUME_OR_RATE <= level <= MAX_VOLUME_OR_RATE for level in levels) + + def test_a_note_played_past_its_envelope_holds_what_the_slice_ends_on( + self, + bent_document: LoadedProject, + ) -> None: + """Every dimension circles from the value it states, so a tick past the end reads the + value the slice rests on. + """ + instrument = bent_document.instruments[0] + assert instrument.value("volumeOrRate", len(VOLUME_ENVELOPE) + 10) == VOLUME_ENVELOPE[-1] + + def test_every_macro_holds_the_values_a_bitphase_instrument_stores(self, document: LoadedProject) -> None: + assert all( + len(macro.values) <= MAX_MACRO_LENGTH + for instrument in document.instruments + for macro in instrument.macros.values() + ) diff --git a/tests/suite/bitphase.py b/tests/suite/bitphase.py index cb4b6b6ca..729fc7af6 100644 --- a/tests/suite/bitphase.py +++ b/tests/suite/bitphase.py @@ -30,6 +30,8 @@ BITPHASE_DEFAULT_LOOP: Final[int] = 0 BITPHASE_DEFAULT_TABLE_ID: Final[int] = 0 BITPHASE_MAX_MACRO_LENGTH: Final[int] = 512 +BITPHASE_SILENT_PERIOD: Final[int] = 0 +BITPHASE_MAX_PERIOD: Final[int] = 2047 BITPHASE_MACRO_DEFAULTS: Final[Dict[str, Any]] = { "pulseWidth": 2, "volumeOrRate": 15, @@ -115,6 +117,19 @@ def macro(self, field: str) -> LoadedMacro: """ return self.macros.get(field, LoadedMacro(values=[BITPHASE_MACRO_DEFAULTS[field]], loop=0)) + def value(self, field: str, tick: int) -> Any: + """The value a field takes on a tick of a sounding note. + + Args: + field: The instrument field, named as Bitphase keys it. + tick: Ticks since the note started. + + Returns: + Any: The value the engine samples for that field. + """ + macro = self.macro(field) + return macro.values[sample_index(tick, len(macro.values), macro.loop)] + @dataclass(frozen=True) class LoadedTable: @@ -124,6 +139,10 @@ class LoadedTable: rows: List[int] additive: bool + def step(self, tick: int) -> int: + """The semitone step the table moves the note by on a tick of a sounding note.""" + return self.rows[sample_index(tick, len(self.rows), self.loop)] + @dataclass(frozen=True) class LoadedSong: @@ -149,6 +168,61 @@ class LoadedProject: instruments: List[LoadedInstrument] +def sample_index(tick: int, length: int, loop: int) -> int: + """The index a per-tick list stands at on a tick, as Bitphase's engine advances it. + + An instrument macro and a table each advance one entry per tick and circle once they run + out, from the loop entry where it stands among them and from the first otherwise. Read from + ``sampleInstrumentMacroIndex`` and ``processTables`` of the tracker at commit ``265ff70``. + + Args: + tick: Ticks since the note started. + length: Entries the list holds. + loop: Entry the list circles from. + + Returns: + int: The entry to read. + """ + entries = length if length > 0 else 1 + if tick < entries: + return max(tick, 0) + + start = loop if 0 < loop < entries else 0 + span = entries - start + if span <= 0: + return entries - 1 + + return start + (tick - entries) % span + + +def sounded_period( + tuning_table: List[int], + note_index: int, + instrument: LoadedInstrument, + table: LoadedTable, + tick: int, +) -> int: + """The channel period a tone channel sounds on a tick, as the engine resolves it. + + The table moves the note, the tuning table resolves the period that note sounds at, and the + instrument's tone offset moves it from there. Read from ``nes-audio-driver.js`` of the + tracker at commit ``265ff70``, where a period of zero silences the channel. + + Args: + tuning_table: The song's period per note index. + note_index: The note the pattern cell names. + instrument: The instrument the cell triggers. + table: The table the cell attaches. + tick: Ticks since the note started. + + Returns: + int: The period the channel holds, within the timer's range. + """ + moved = min(max(note_index + table.step(tick), 0), len(tuning_table) - 1) + period = tuning_table[moved] + instrument.value("toneAdd", tick) + return min(max(period, BITPHASE_SILENT_PERIOD), BITPHASE_MAX_PERIOD) + + def _note(data: Optional[Dict[str, Any]]) -> LoadedNote: source = data or {} return LoadedNote( From 5f516ef1b7762de0edf29b7c170d68dc104a7232 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 20 Sep 2026 02:36:55 +0200 Subject: [PATCH 6/7] Documented: the Bitphase macro instrument and its bend --- CHANGELOG.md | 1 + docs/formats/bitphase.md | 155 +++++++++++++++++++++++++-------------- 2 files changed, 100 insertions(+), 56 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 580c77cfa..da4937a12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ * Added NSF player and export. * Added stems conversion: to mix several recordings into one reconstruction. +* Updated the Bitphase export to the tracker's current instrument format, with pitch bends. * Improved clarity of reconstructions. * Optimized the size of reconstructions. * Bumped the reconstruction data-version to `2.2` with backward compatibility for `2.1`. diff --git a/docs/formats/bitphase.md b/docs/formats/bitphase.md index 7a6ddc57a..e401397e5 100644 --- a/docs/formats/bitphase.md +++ b/docs/formats/bitphase.md @@ -33,13 +33,15 @@ misses, so a document that carries every field below loads exactly as it was wri ``` Project { name, author, songs[], loopPointId, patternOrder[], tables[], patternOrderColors{}, instruments[] } -Song { patterns[], tuningTable[], initialSpeed, chipType, chipVariant, - chipFrequency, interruptFrequency, a4TuningHz, virtualChannelMap{} } +Song { patterns[], tuningTable[], initialSpeed, defaultPatternLength, chipType, + chipVariant, chipFrequency, interruptFrequency, a4TuningHz, + virtualChannelMap{} } Pattern { id, length, channels[], patternRows[] } -Channel { rows[], label } +Channel { rows[], label, effectColumnCount } Row { note: { name, octave }, effects[], instrument, table, volume } -Table { id, rows[], loop, name } -Instrument { id, chipType, rows[], loop, name } +Effect { effect, delay, parameter, tableIndex } +Table { id, rows[], loop, name, additive } +Instrument { chipType, name, macros{ field: { values[], loop } }, id } ``` Instruments and tables belong to the **project** rather than to a song, so every song @@ -50,40 +52,50 @@ and `loopPointId` is the order position playback returns to. carry snake_case attributes and serialize through a camelCase alias generator, so the Python side reads like the rest of the codebase while the file reads like Bitphase's. +**A channel lays out as many effect columns as its widest line carries**, up to four, and +Bitphase holds that count across every pattern the channel appears in. An effect reads +from a table wherever its cell names an index of zero or above, so a cell driven by its +own parameter states `-1`; a cell carrying an empty value counts as naming the first +table, which is why the effect column never writes one. + ### A.2 `.json` — the instrument preset Bitphase's instruments panel saves and loads a single instrument at runtime through a -file picker. The file holds `{ chipType, name, loop, rows }`, indented the way Bitphase +file picker. The file holds `{ chipType, name, macros }`, indented the way Bitphase writes its own, so a preset written here reads like one saved from the tracker. Written -by `formats/bitphase/preset.py`. +by `formats/bitphase/preset.py`. The panel writes the macros into the slot the reader has +selected, which supplies the id and the chip. -A preset carries rows alone, so its pitch movement rides in each row's `toneAdd` -(section C.3) rather than in a table. +A preset carries macros alone, so its pitch movement rides in the `toneAdd` each tick +takes (section C.3) rather than in a table. ## B. The NES instrument -An instrument advances **one row per engine tick** while a note sounds, so a row -carries every register value the channel takes for that tick. From -`formats/bitphase/model/instrument.py`, matching Bitphase's `NesInstrumentRow`: - -| Field | Range | Runtime meaning | What the exporter writes | -| --- | --- | --- | --- | -| `pulseWidth` | 0–3 | square duty cycle; on the noise channel, any nonzero value selects the short LFSR | the duty-cycle envelope item (squares), the short/long mode (noise), a flat value (triangle) | -| `volumeOrRate` | 0–15 | the literal channel volume while `envelope` stays off | the volume envelope item, or a full level where the slice leaves its volume to the channel | -| `envelope` | bool | reads `volumeOrRate` as a hardware decay rate | `false`, so each item is the volume itself | -| `soundLength` | 0–511 | length counter in ticks; `0` holds the note | `0`, so the volume envelope alone shapes the note | -| `toneAdd` | −4096–4095 | period offset added to the tuning-table period (squares and triangle) | `0` in a document, the pitch contour in a preset | -| `toneAccumulation` | bool | sums `toneAdd` across ticks | `false`, since each item is an absolute offset | -| `retrigger` | bool | restarts the waveform phase this tick | `false`, so the waveform runs continuously | -| `sweep` / `sweepRate` / `sweepShift` | bool / 0–7 / −7–7 | the square channel's hardware sweep | disabled | - -**Looping.** Playback returns to the instrument's `loop` row once it runs off the end, -which is the only mode there is. Bitphase reads every dimension out of one row, so the -instrument returns to the earliest row any dimension repeats from and each dimension goes -on sounding what it would have sounded. A slice whose dimensions all halt sets -`loop = len - 1` and rests on the level that row carries — silence where the volume -envelope ends on a note-off item, the channel's own level where the slice holds its -volume. +An instrument is a **bag of macros**, one per field whose values it decides, and each +macro is read a value per engine tick while a note sounds. A field the bag leaves out +takes Bitphase's own default, so an instrument states the fields a reconstruction +governs and nothing else. From `formats/bitphase/model/instrument.py` and +`specification/macros.py`, matching Bitphase's `NES_APU_MACRO_FIELDS`: + +| Field | Range | Default | Runtime meaning | What the exporter writes | +| --- | --- | --- | --- | --- | +| `volumeOrRate` | 0–15 | 15 | the literal channel volume while `envelope` stays off | the volume envelope, or one full level where the slice leaves its volume to the channel | +| `pulseWidth` | 0–3 | 2 | square duty cycle; on the noise channel, any nonzero value selects the short LFSR | the duty-cycle envelope (squares), the short/long mode (noise); the triangle leaves it out | +| `toneAdd` | −4096–4095 | 0 | period offset added to the period the note resolves to (squares and triangle) | the bend a slice sounds (section C.4), and the contour beside it in a preset | +| `envelope` | bool | `false` | reads `volumeOrRate` as a hardware decay rate | left out, so each value is the volume itself | +| `soundLength` | 0–511 | 0 | length counter in ticks; `0` holds the note | left out, so the volume envelope alone shapes the note | +| `toneAccumulation` | bool | `false` | sums `toneAdd` across ticks | left out, since each value is an absolute offset | +| `retrigger` | bool | `false` | restarts the waveform phase this tick | left out, so the waveform runs continuously | +| `sweep` / `sweepRate` / `sweepShift` | bool / 0–7 / −7–7 | `false` / 0 / 0 | the square channel's hardware sweep | left out | + +**Each field runs on a counter of its own.** A macro carries the values one field takes +and the index those values circle from, so a dimension holding one value all through +costs that one value however long the others run. Playback circles from the loop index +once the values run out, which makes a macro standing at index `0` circle whole and one +standing at its last value hold that value. A dimension the reconstruction states a +repeat point for circles from that point; one that plays through states its last index +and rests on the value it ends with — silence where the volume envelope ends on a +note-off item, the channel's own level where the slice holds its volume. **A hand-written instrument's slices.** Bitphase bakes a channel's registers tick by tick, so an [instrument](../glossary.md#instrument) written by hand reaches a document @@ -92,18 +104,11 @@ and moving around the pitch it states. The envelopes are one set whatever the ch so the slices differ only in what each channel reads of them. **A held volume.** A slice whose volume envelope carries no item leaves its level to the -channel, so the exporter writes a full `volumeOrRate` for every frame the slice -describes. Playback combines a row's level with the pattern's volume column through a -PT3 volume table, where a full-level row comes out at the column's own level, so those -rows sound at whatever level the channel carries — the same reading FamiTracker gives a -disabled volume sequence. A slice describing no frame at all is what writes a single -silent row, the smallest instrument Bitphase plays. - -**Equal lengths.** Instrument rows and table rows advance on independent per-tick -counters, so they share a length and a loop point and stay in step for as long as the -note sounds. The slice's longest dimension supplies that shared length, and every -shorter one holds the value it ended on for the rest of it (`Envelope.resized`), which -is what the sequences of a FamiTracker instrument each do on a counter of their own. +channel, so the exporter writes one full `volumeOrRate`. Playback combines that level with +the pattern's volume column through a PT3 volume table, where a full level comes out at +the column's own level, so the slice sounds at whatever level the channel carries — the +same reading FamiTracker gives a disabled volume sequence. A slice describing no frame at +all is what writes a single silent value, the smallest instrument Bitphase plays. ## C. Pitch @@ -133,9 +138,11 @@ an octave below — the convention SampleToNES and FamiTracker already share. ### C.2 Tables carry the contour A table holds one semitone offset per tick, and playback adds `rows[position]` to the -channel's note every tick. That is a direct match for a reconstruction's arpeggio -envelope in absolute mode, so the contour crosses over verbatim on the pitched -channels. +channel's note every tick, advancing a step per tick and circling from `loop` once the +steps run out. That is a direct match for a reconstruction's arpeggio envelope in absolute +mode, so the contour crosses over verbatim on the pitched channels, carrying the repeat +point the envelope states. A table whose `additive` flag stands measures each step from +the one before it; a contour states its steps from the note, so the flag stays clear. A pattern's `table` column names a table by `id + 1`; `0` leaves the attached table alone and `-1` detaches it. @@ -155,11 +162,30 @@ the sixteen the channel holds. ### C.3 Presets fold the contour into the period -An instrument preset carries no table, so its pitch movement is expressed as the -per-tick `toneAdd` each row applies to the note's own period. The offsets are measured -against the pitch the slice was reconstructed at, under the tuning a freshly created -Bitphase document plays — NTSC at concert pitch. The noise channel takes its period -from the note, so its preset rows hold a flat offset. +An instrument preset carries no table, so its pitch movement is expressed as the per-tick +`toneAdd` each tick applies to the note's own period, the contour and the bend together. +The offsets are measured against the pitch the slice was reconstructed at, under the +tuning a freshly created Bitphase document plays — NTSC at concert pitch. The noise +channel takes its period from the note, so its preset holds a flat offset. + +### C.4 The bend rides the tone offset + +A reconstruction states a note and, beside it, the timer steps the frame stands away from +that note — the [bend](../glossary.md#bend) an instruction carries as a fine detune and a +coarse one, sixteen steps to the unit. Bitphase counts a period where SampleToNES counts a +timer, and the two differ by one step throughout the table, so a difference of steps +crosses over unchanged: the bend is the `toneAdd` macro, one value per tick. + +| What the engine reads | What it means for the export | +| --- | --- | +| the table moves the note, then the tuning table resolves its period | each tick's offset is measured from the note its own contour step reaches, so a transposed trigger keeps the bend it was written with | +| `toneAdd` is added to that period | the offset is what the reconstruction's two bend dimensions state together (`exporters/bend.py`) | +| `toneAccumulation` stays clear | every value is the whole offset for its tick, rather than a step added to a running one | +| a period of zero silences the channel | an offset is held to what keeps the period within 1–2047, which is `bent_timer` read in periods (`formats/bitphase/pitch.py`) | + +The squares and the triangle read the offset; the noise channel takes its period from the +note alone, so a noise slice states no `toneAdd` at all. A slice sounding every tick on +its own note states none either, so a document only pays for the bends it sounds. ## D. Tempo as a groove @@ -186,8 +212,7 @@ its entries per pattern row, which is what carries a per-row tick count into a s | Its place | the first row of the DPCM channel, in every pattern | A speed effect applies from whichever channel carries it, so the groove rides the DPCM channel -this exporter leaves silent and every sounding channel keeps the one effect column the chip -gives it. The table advances an entry per row and resumes from where a trigger placed it, so +this exporter leaves silent and every sounding channel keeps its own effect column free. The table advances an entry per row and resumes from where a trigger placed it, so triggering it again at each pattern start holds every row on the entry that describes it, however the order jumps. @@ -236,8 +261,9 @@ you would see in the tracker either way. | Quantity | Bitphase limit | Exporter behavior | | --- | --- | --- | -| Items per instrument row list | unbounded | writes the envelope whole | +| Values per instrument macro | 1–512 | keeps a longer dimension's opening values, and a volume ending in silence keeps that silence as its last | | Rows per table | unbounded | writes the contour, or the groove, whole | +| Effect columns per channel | 1–4 | one, which the groove trigger takes on the DPCM channel | | Instruments | the instrument column holds 2 base-36 digits, so 1–1295 | raises past 1295 | | Tables | the table column holds 1 base-36 digit, so ids 0–34 | raises past 35 tables, one of which a groove takes | | Note range | the 96-entry tuning table, pitch 24–119 | clamps to the nearest playable note | @@ -253,11 +279,28 @@ writing a document whose later voices cannot be named. A song whose rows vary sp of those ids on its groove, so the slices a document holds are those the table column can still name. +A macro is the one limit a reconstruction meets by itself: 512 values is about eight and a +half seconds at 60 Hz. Each dimension is counted on its own, so a constant duty or a held +level costs one value, and the contour a table carries keeps its whole length whatever the +macros beside it hold. The rule an envelope meets that limit by is `features/limits.py`, +shared with the FamiTracker export. + ## G. What does not cross over **`ProjectInfo.comment`** has no counterpart in a Bitphase document, which carries a name and an author only, so the exporter leaves the comment behind. +**A document plays at concert pitch.** `a4TuningHz` and the tuning table are written at +A4 = 440 Hz, so a reconstruction tuned elsewhere sounds a document at the pitch Bitphase +creates one with. The distance is recorded in +[bugs and to-dos](../development/bugs-and-todos.md). + +**The fields a reconstruction governs are the three it states.** The hardware envelope, +the length counter, the phase retrigger, the sweep and the tone accumulator each stay at +the default Bitphase gives a field the instrument leaves out, and the DPCM sample fields an +instrument may carry are stored by the tracker rather than played, so the exporter writes +none of them. + `interruptFrequency` carries the reconstruction's own tick rate. Bitphase's settings -panel offers 50 and 60 Hz, and its loader and timeline accept any value, so a rate -outside that pair plays correctly while leaving that one selector unmatched. +panel offers 50 and 60 Hz beside a custom value, and its loader and timeline accept any +rate, so one outside that pair plays correctly. From 70f8874c40da0f5bb39e7743eff2e2cb6132e6a5 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 21 Sep 2026 22:17:00 +0200 Subject: [PATCH 7/7] Documented: what a shortened Bitphase dimension leaves behind --- docs/development/bugs-and-todos.md | 2 ++ docs/formats/famitracker.md | 8 ++++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 126c0f35e..603ffed85 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -31,6 +31,8 @@ dimension the import starts carrying. their own, and scheme needs the item bit-packing FamiTracker gives it. * A Bitphase document is written at concert pitch whatever the reconstruction was tuned at. The song builder reads the default tuning and leaves the request's own tuning unread. +* A Bitphase export shortens a dimension past 512 values and reports nothing. The FamiTracker export + reports what it left out, and the instruments panel draws its warning from that report alone. * A transpose or a volume typed in the sample column of a row with no sample reaches every channel. The column falls back to all four channels so the value lands somewhere, and the reference slot keeps the narrower reading and stays empty. diff --git a/docs/formats/famitracker.md b/docs/formats/famitracker.md index 2aa6a9052..54530d83e 100644 --- a/docs/formats/famitracker.md +++ b/docs/formats/famitracker.md @@ -228,10 +228,10 @@ writes the release item, so a volume dimension is one item longer than the frame **The item limit.** A FamiTracker sequence holds up to 252 items. An envelope keeps whatever length it was written at, and a writer applies its own format's limit at export. A dimension over the limit is written as its opening items. A volume dimension keeps its release as the last item, because the note has to end, -so the release displaces the last sounding item that would not fit. [The Bitphase -export](bitphase.md#f-bitphase-capacity-limits) meets its own limit by the same rule. A reconstruction reaches the limit at 252 -frames, since its volume carries the release past them. At the default 30 fps that is 8.4 s. The export -reports what it left out. +so the release displaces the last sounding item that would not fit. A reconstruction reaches the limit at +252 frames, since its volume carries the release past them. At the default 30 fps that is 8.4 s. The +export reports what it left out. [The Bitphase export](bitphase.md#f-bitphase-capacity-limits) shortens a +dimension by the same rule, at its own limit. **Empty dimensions.** An empty dimension is written as a disabled sequence. This differs from a sequence with a single zero: a disabled slot leaves that dimension to the channel, while a one-item sequence sets