diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 643379dbd..603ffed85 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -29,8 +29,10 @@ dimension the import starts carrying. driver and `NoteOff` to gain one. * Arpeggio modes. A sequence's `setting` byte says 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. `NesInstrumentRow` already has `tone_add` and `tone_accumulation`, so the - mapping stays inside `formats/bitphase/envelopes.py`. +* 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/bitphase.md b/docs/formats/bitphase.md index 21c146c68..bd65137c5 100644 --- a/docs/formats/bitphase.md +++ b/docs/formats/bitphase.md @@ -29,13 +29,15 @@ with every field below loads exactly as it was written. ``` 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 } ``` The project owns the instruments and tables, so every song addresses the same lists. `patternOrder` names @@ -43,36 +45,48 @@ the pattern each order position plays. `loopPointId` is the order position playb **Field names are camelCase**, as in Bitphase's own files. +A channel has as many effect columns as its widest row, up to four, and Bitphase gives a channel the same +count in every pattern it appears in. An effect reads its argument from a table whenever `tableIndex` is +zero or above, and from `parameter` otherwise, so an effect driven by its parameter writes `tableIndex` +as `-1`. An empty value counts as naming the first table, and the effect is then dropped. + ### A.2 `.json` — the instrument preset Bitphase's instruments panel saves and loads a single instrument through a file picker. The file holds -`{ chipType, name, loop, rows }`, indented the way Bitphase writes its own, so a preset written here reads -like one saved from the tracker. +`{ chipType, name, macros }`, indented the way Bitphase writes its own, so a preset written here reads +like one saved from the tracker. The panel writes the macros into the instrument slot the reader has +selected, and that slot gives the instrument its id and its chip. -A preset has rows only, so its pitch movement goes in each row's `toneAdd` (section C.3) instead of a -table. +A preset has macros only, so its pitch movement goes in the `toneAdd` each tick takes (section C.3) +instead of a table. ## B. The NES instrument -An instrument advances **one row per engine tick** while a note sounds, so a row has every register value -the channel takes for that tick. The fields match 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. That is the only -mode. 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 has: 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 set of **macros**, one per field, and each macro gives that field a value per engine +tick while a note sounds. A field with no macro takes the default below, so an instrument writes a macro +only for the fields a reconstruction decides. The fields match Bitphase's own: + +| 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 or long mode (noise); the triangle writes no macro | +| `toneAdd` | −4096–4095 | 0 | period offset added to the period the note resolves to (squares and triangle) | the bend the slice sounds (section C.4), and the contour with it in a preset | +| `envelope` | bool | `false` | reads `volumeOrRate` as a hardware decay rate | no macro, so each value is the volume itself | +| `soundLength` | 0–511 | 0 | length counter in ticks; `0` holds the note | no macro, so the volume envelope alone shapes the note | +| `toneAccumulation` | bool | `false` | sums `toneAdd` across ticks | no macro, so each value is a whole offset | +| `retrigger` | bool | `false` | restarts the waveform phase this tick | no macro, so the waveform runs continuously | +| `sweep` / `sweepRate` / `sweepShift` | bool / 0–7 / −7–7 | `false` / 0 / 0 | the square channel's hardware sweep | no macro, so the sweep stays off | + +**Every field has its own counter.** A macro has the values one field takes and the index they repeat +from, and Bitphase advances each macro on its own. A field whose value never changes therefore costs one +value, however long the other fields run. + +**Looping.** Playback returns to the macro's `loop` index once the values run out. That is the only mode. +A macro whose `loop` is `0` repeats whole, and one whose `loop` is its last index stays on the last value. +A dimension the reconstruction gives a repeat point repeats from that point. A dimension that plays +through writes its last index, and the value it ends on is what the note rests on: silence where the +volume envelope ends on a note-off item, the channel's own level where the slice leaves the volume +alone. **A hand-written instrument's slices.** Bitphase bakes a channel's registers tick by tick. An [instrument](../glossary.md#instrument) written by hand therefore reaches a document as one slice per @@ -81,16 +95,14 @@ instrument states. The envelopes are one set for every channel, so the slices di channel reads of them. **A held volume.** A slice whose volume envelope has no item leaves its level to the channel. 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. Those rows therefore sound at whatever level the channel has, which is how FamiTracker reads a -disabled volume sequence. A slice that describes no frame at all writes a single silent row, the smallest -instrument Bitphase plays. +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. The slice therefore sounds at +whatever level the channel has, which is how FamiTracker reads a disabled volume sequence. A slice that +describes no frame at all writes a single silent value, 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 to stay in step for as long as the note sounds. The slice's longest dimension sets -that length. Every shorter dimension holds the value it ended on for the rest of it (`Envelope.resized`), -as the sequences of a FamiTracker instrument each do on a counter of their own. +**A table runs beside the macros.** The table advances one step per tick on a counter of its own, so the +contour keeps the length and the repeat point the arpeggio envelope was written at, whatever the macros +beside it do. ## C. Pitch @@ -118,8 +130,10 @@ _SampleToNES_ and FamiTracker share that convention. ### C.2 Tables carry the contour A table has one semitone offset per tick, and playback adds `rows[position]` to the channel's note every -tick. That matches a reconstruction's arpeggio envelope in absolute mode, so the contour crosses over -verbatim on the pitched channels. +tick. It repeats from `loop` once the steps run out, by the rule a macro repeats by. That matches a +reconstruction's arpeggio envelope in absolute mode, so the contour crosses over verbatim on the pitched +channels, with the repeat point the envelope was written at. A table whose `additive` flag is set adds +each step to the one before it; a contour measures every step 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. @@ -138,10 +152,30 @@ channel has. ### C.3 Presets fold the contour into the period -An instrument preset has no table, so its pitch movement is 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 has no table, so its pitch movement is the per-tick `toneAdd` each tick applies to +the note's own period. One offset carries 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 a preset for it has a +flat offset. + +### C.4 The bend rides the tone offset + +A reconstruction says which note a frame plays and how far from that note it sounds, in steps of the +channel's own timer. That distance is the [bend](../glossary.md#bend), written as a fine dimension of one +step per unit and a coarse one of sixteen. Bitphase counts a period where _SampleToNES_ counts a timer, +and the two differ by one step across the table, so a distance in steps crosses over unchanged. The bend +is the `toneAdd` macro, one value per tick. + +| What the engine does | What the exporter writes | +| --- | --- | +| moves the note by the table step, then reads that note's period | each value is measured from the note its own contour step reaches, so a transposed trigger keeps its bend | +| adds `toneAdd` to that period | the two bend dimensions added together, one value per tick | +| leaves `toneAccumulation` clear | a whole offset per tick, not a step added to a running one | +| silences a channel whose period reaches zero | an offset bounded to keep the period within 1–2047, the rule the timer follows | + +The squares and the triangle read the offset. The noise channel takes its period from the note alone, so +a noise slice writes no `toneAdd`. A slice that sounds every tick on its own note writes none either, so +a document pays only for the bends it sounds. ## D. Tempo as a groove @@ -167,7 +201,7 @@ per pattern row, and that carries a per-row tick count into a song: | Its place | the first row of the DPCM channel, in every pattern | A speed effect applies from whichever channel has it, so the groove rides the DPCM channel, which this -exporter leaves silent. Every sounding channel keeps the one effect column the chip gives it. The table +exporter leaves silent. Every sounding channel keeps its own effect column free. The table advances an entry per row and resumes from where a trigger placed it. Triggering it at each pattern start therefore holds every row on the entry that describes it, however the order jumps. @@ -212,8 +246,9 @@ is the same cell you would see in the tracker. | Quantity | Bitphase limit | Exporter behavior | | --- | --- | --- | -| Items per instrument row list | unbounded | writes the envelope whole | +| Values per instrument macro | 1–512 | writes the opening values of a longer dimension, and keeps a volume's closing silence | | Rows per table | unbounded | writes the contour, or the groove, whole | +| Effect columns per channel | 1–4 | writes 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 | @@ -228,11 +263,25 @@ therefore what a wide document reaches first, and the exporter raises an error i document whose later voices cannot be named. A song whose rows vary spends one of those ids on its groove, so the slices a document holds are those the table column can still name. +**The macro limit is the one a reconstruction meets by itself.** A dimension reaches it at 512 frames, +which is 8.5 s at 60 Hz. Each field is counted on its own, so a flat duty or a held level costs one value, +and the contour the table carries keeps its whole length. A volume dimension keeps its closing silence as +its last value, because the note has to end. [The FamiTracker export](famitracker.md#b-the-2a03-instrument) +meets its own limit by the same rule. + ## G. Data without a counterpart **`ProjectInfo.comment`** has no counterpart in a Bitphase document, which has a name and an author only, so the exporter leaves the comment out. +**A document is written 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 gives a new one. The distance +is recorded in [bugs and to-dos](../development/bugs-and-todos.md). + +**A field a reconstruction does not decide gets no macro.** The hardware envelope, the length counter, the +phase retrigger, the sweep and the tone accumulator each take the default in section B. Bitphase also +stores DPCM sample data on an instrument but never plays it, so the exporter writes none of it. + `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. A rate outside that pair plays correctly, and the -panel's selector shows no match. +60 Hz beside a custom value, and its loader and timeline accept any rate. A rate outside that pair plays +correctly. diff --git a/docs/formats/famitracker.md b/docs/formats/famitracker.md index 7ef79c37d..54530d83e 100644 --- a/docs/formats/famitracker.md +++ b/docs/formats/famitracker.md @@ -225,12 +225,13 @@ stops the note. The driver holds a halted sequence's last value for as long as a sounding, so a volume envelope that ended audible would sound to the end of the song. Every generator writes the release item, so a volume dimension is one item longer than the frames it describes. -**The item limit.** A FamiTracker sequence holds up to 252 items. Only this writer applies the limit: an -envelope keeps whatever length it was written at until 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. 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 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. 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 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/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/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/bitphase/builder.py b/src/sampletones_core/formats/bitphase/builder.py index ba8bc48c4..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, ) @@ -196,6 +195,7 @@ def _build_song( *, speed: int, nes_frequency: int, + pattern_length: int, ) -> BitphaseSong: chip_frequency = CPU_FREQUENCIES[DEFAULT_CHIP_VARIANT] return BitphaseSong( @@ -205,6 +205,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 +298,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 +588,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/envelopes.py b/src/sampletones_core/formats/bitphase/envelopes.py index 4984383ee..0f32cfb8b 100644 --- a/src/sampletones_core/formats/bitphase/envelopes.py +++ b/src/sampletones_core/formats/bitphase/envelopes.py @@ -1,11 +1,14 @@ 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.constants.enums import TONE_CHANNELS, 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.model.instrument import NesInstrumentRow -from sampletones_core.formats.bitphase.notes import noise_arpeggio_to_table_offset +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, 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, @@ -15,30 +18,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 +69,113 @@ 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 + 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) + + return HELD_VOLUME_ENVELOPE + + +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, FLAT_CONTOUR), + table_rows=FLAT_CONTOUR.items, + table_loop=LOOP_FROM_START, + ticks=0, + ) + + +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)) + + if channel in TONE_CHANNELS: + bend = _bend_envelope(features, contour) + if bend.written: + macros[NesMacroField.TONE_ADD] = macro(bend) + + return macros + - 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. +def _bend_envelope(features: Features, 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 reads 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 that sounds + every tick on its note asks for no offset at all. Args: - frames: The frames the slice describes. + features: The per-dimension envelopes describing the slice. + contour: The semitone steps the table moves the note by. Returns: - Tuple[int, ...]: One full-level item per frame. + Envelope[int]: The offset per tick, empty where the slice sounds on its note throughout. """ - return (MAX_VOLUME_OR_RATE,) * frames + 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, ) -> 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, 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. Args: @@ -101,57 +183,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, contour) 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. +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 - 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. + return len(envelope.items) - 1 - Args: - envelopes: The dimensions the instrument writes. - rows: How many rows the instrument holds. - - 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/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/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 1d2275450..168cb2d7e 100644 --- a/src/sampletones_core/formats/bitphase/preset.py +++ b/src/sampletones_core/formats/bitphase/preset.py @@ -2,52 +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.formats.bitphase.envelopes import features_to_envelopes -from sampletones_core.formats.bitphase.model.instrument import BitphaseInstrumentPreset, NesInstrumentRow +from sampletones_core.features.envelope import Envelope +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.specification.patterns import MAX_NOTE_INDEX, MIN_NOTE_INDEX -from sampletones_core.formats.bitphase.tuning import generate_tuning_table - -PRESET_TUNING_TABLE: Final[Tuple[int, ...]] = generate_tuning_table( - DEFAULT_CPU_FREQUENCY, - a4_tuning=DEFAULT_A4_TUNING, -) +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.tuning import DEFAULT_TUNING_TABLE + 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 rows alone, so its pitch movement rides in each row's tone offset. - 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. + """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) @@ -66,25 +62,37 @@ def instrument_to_preset(request: InstrumentExport) -> BitphaseInstrumentPreset: request.channel, ) offsets = _tone_offsets( + request.features, request.channel, - 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, - ) + _contour(envelopes), ) 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)), + }, ) +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/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/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/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/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/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 fc96efa83..f0b485829 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, @@ -75,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) @@ -127,7 +126,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 +142,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 +295,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/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 c511104d6..729fc7af6 100644 --- a/tests/suite/bitphase.py +++ b/tests/suite/bitphase.py @@ -22,15 +22,33 @@ 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 -BITPHASE_DEFAULT_PULSE_WIDTH: Final[int] = 2 -BITPHASE_DEFAULT_VOLUME_OR_RATE: Final[int] = 15 +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, + "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 +MIN_PATTERN_LENGTH: Final[int] = 1 +MAX_PATTERN_LENGTH: Final[int] = 256 @dataclass(frozen=True) @@ -60,6 +78,7 @@ class LoadedRow: class LoadedChannel: label: str rows: List[LoadedRow] + effect_column_count: int @dataclass(frozen=True) @@ -70,32 +89,47 @@ 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)) + + 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: @@ -103,6 +137,11 @@ class LoadedTable: loop: int name: str 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) @@ -113,6 +152,7 @@ class LoadedSong: interrupt_frequency: int a4_tuning_hz: float initial_speed: int + default_pattern_length: int tuning_table: List[int] patterns: List[LoadedPattern] @@ -128,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( @@ -136,6 +231,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 +256,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 +278,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 LoadedChannel(label=label, rows=[_row(row) for row in rows]) + return max( + (len(row.effects) for row in rows), + default=BITPHASE_MIN_EFFECT_COLUMNS, + ) + + +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: @@ -186,19 +316,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: @@ -207,9 +334,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), ) @@ -219,6 +345,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 +357,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 +374,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/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/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/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]) 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_btp.py b/tests/unit/sampletones_core/formats/bitphase/test_btp.py index 1c65bdd76..671f386b5 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", @@ -43,20 +44,9 @@ ] 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", -] -TABLE_KEYS: Final[List[str]] = ["id", "rows", "loop", "name"] +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"] @pytest.fixture(name="project") @@ -120,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: @@ -134,7 +128,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: @@ -154,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] - - 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) + assert document["instruments"][1]["macros"]["pulseWidth"]["values"] == [1, 1, 0, 0] - 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_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 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..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, @@ -19,6 +15,8 @@ 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 sampletones_shared.paths.extensions import EXT_FILE_JSON from .conftest import REFERENCE_PITCH, build_features, build_instrument @@ -26,7 +24,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 +53,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 + 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_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 +101,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 +109,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 +119,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())