diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bb6498e4..af14331fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## v0.3.2 +* Added the tracker playback check: export your own project to [FamiTracker](http://famitracker.com) or [Bitphase](https://github.com/paator/bitphase), play it with the tracker's own playback code, and see every tick a channel sounds differently from _SampleToNES_. Run `sampletones tracker-playback`; the [guide](https://github.com/JakimPL/SampleToNES/blob/main/docs/tools/tracker-playback.md) explains what it needs and how to read its report. +* Changed how the noise channel plays a hand-written instrument, to match FamiTracker and Bitphase: an odd duty cycle value plays the short mode, and the pitch envelope moves the noise period. * Added NSF player and export. * Added stems conversion: to mix several recordings into one reconstruction. * Changed drive to reach for louder instructions while a recording converts; reconvert anything converted at a drive other than `1.00`. diff --git a/Makefile b/Makefile index f4d39edd2..c856e87ad 100644 --- a/Makefile +++ b/Makefile @@ -13,6 +13,7 @@ Q := " endif GPU ?= auto +PROJECT_OPTIONS = $(foreach project,$(PROJECT),--project $(project)) help: @echo $(Q)Available targets:$(Q) @@ -25,7 +26,7 @@ help: @echo $(Q) make test-docs - Run the doctests$(Q) @echo $(Q) make benchmarks - Run the measured-duration suite$(Q) @echo $(Q) make calibration - Measure reconstruction on the reference sounds; writes the renders and a report$(Q) - @echo $(Q) make tracker-playback BITPHASE=folder - Play the exported corpus with a Bitphase checkout; reports every tick that differs from the app$(Q) + @echo $(Q) make tracker-playback BITPHASE=folder FAMITRACKER=FamiTracker.exe [PROJECT=song.stp] - Play exported projects (the corpus by default) in Bitphase, FamiTracker or both; reports every tick that differs from the app$(Q) @echo $(Q) make clean - Remove build artifacts and cache files$(Q) @echo $(Q) make lint - Run mypy and pylint (ARGS=--mypy or ARGS=--pylint for one of them)$(Q) @echo $(Q) make format - Auto-format code (isort, black)$(Q) @@ -54,7 +55,9 @@ calibration: uv run sampletones calibration tracker-playback: - uv run sampletones tracker-playback bitphase --checkout $(BITPHASE) + $(if $(BITPHASE)$(FAMITRACKER),,$(error Give BITPHASE=folder, FAMITRACKER=path/to/FamiTracker.exe, or both)) + $(if $(BITPHASE),uv run sampletones tracker-playback bitphase --checkout $(BITPHASE) $(PROJECT_OPTIONS)) + $(if $(FAMITRACKER),uv run sampletones tracker-playback famitracker --executable $(FAMITRACKER) $(PROJECT_OPTIONS)) clean: $(PYTHON) scripts/clean.py diff --git a/THIRD-PARTY-LICENSES.txt b/THIRD-PARTY-LICENSES.txt index ad4a8c25d..5e81183d2 100644 --- a/THIRD-PARTY-LICENSES.txt +++ b/THIRD-PARTY-LICENSES.txt @@ -4931,6 +4931,46 @@ ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +==================================================================================================== +py65 1.2.0 +License: BSD-3-Clause +Project: https://pypi.org/project/py65/ +==================================================================================================== + +---------------------------------------------------------------------------------------------------- +[py65-1.2.0.dist-info/LICENSE.txt] +---------------------------------------------------------------------------------------------------- +BSD 3-Clause License + +Copyright (c) 2008-2024, Mike Naberezny and contributors. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + ==================================================================================================== PyAudio 0.2.14 License: MIT License diff --git a/docs/concepts/timing.md b/docs/concepts/timing.md new file mode 100644 index 000000000..182bea343 --- /dev/null +++ b/docs/concepts/timing.md @@ -0,0 +1,133 @@ +# Song timing + +This document explains how a song's tempo becomes the whole number of ticks each row lasts. Read it before +changing the groove, or when you need to know why the rows of a song last unequal ticks. You can read it +without the source code, which lives in `sampletones_core/timing/`. + +## 1. The problem + +A tracker plays a song row by row, and every row lasts a whole number of [ticks](../glossary.md#tick). The +project's settings ask for an exact row length: + +``` +ticks_per_row = speed × nes_frequency × 150 / (tempo × 60) +``` + +At tempo 150 and 60 Hz this is the speed itself. At most other tempi it falls between two whole numbers. +At 60 Hz, speed 6 and tempo 125, a row should last 7.2 ticks. A row can last 7 ticks or 8, so the song +mixes the two, and the mix decides how the rhythm feels. The ticks each row lasts are the song's +[groove](../glossary.md#groove). + +The [meter](../glossary.md#metric-highlight) groups the rows. The first highlight is the beat and the +second is the bar. The defaults, 4 and 16, give bars of four beats of four rows. The tempo counts beats. + +## 2. What a good groove does + +Four properties describe a good groove. + +1. **It keeps time.** Every bar starts on the tick nearest the moment the tempo puts it at. The rounding + never adds up, so the song holds its tempo however long it plays. +2. **It keeps proportion.** Every row lasts the whole number just below or just above its exact length: + 7 or 8 ticks for 7.2. +3. **It keeps accents.** The longer rows go to the strongest positions. In a bar, the first half of the + beats comes first, then the first of each half, and so on down. In a beat, the first row comes first, + then the middle row, then the quarters. At 7.2 ticks a row, a bar of four beats plays: + + ``` + 8 7 7 7 | 8 7 7 7 | 8 7 7 7 | 7 7 7 7 115 ticks, against an exact 115.2 + ``` + +4. **It needs no look-ahead.** A player knows how long a row lasts when it reaches it, without reading the + rows after it. + +**The four can conflict.** Suppose the speed could change in the middle of a beat. A player without +look-ahead would then have to decide the length of a beat's first row before knowing whether the beat +earns a surplus tick, which property 3 wants on that row. No procedure keeps all four in that case, while +any three of them can be kept together. A _SampleToNES_ song has one speed throughout, so its groove keeps +all four. + +**Time is kept at the bar line.** Starting every beat on its nearest tick, too, would put the surplus +wherever the running count happens to land, which breaks property 3. So the bar lines stay exact, and a +beat inside a bar leans toward its strong positions. In common time a beat starts within about a tick of +its exact moment. A bar holding many beats is halved more times, so its beats can stray further: up to +about three ticks in a bar of 256 one-row beats, the deepest the settings allow. + +## 3. How the ticks are placed + +**The rate is held within what the engine plays.** A row lasts at least one tick. A tempo above +`2.5 × speed × nes_frequency` asks for shorter rows, so every row lasts one tick and the song plays slower +than its tempo states. FamiTracker and Bitphase play such a tempo the same way. + +**Each bar takes the ticks between its own start and the next bar's start.** Each start is rounded to its +nearest tick, counted from the song's first row. This is what keeps time: no bar line is more than half +a tick away from its exact moment, however far into the song it falls. + +**Two frames of one pattern can differ by a tick.** At 60 Hz, speed 6 and tempo 210, a 16-row pattern +lasts 68 4/7 ticks. The first frame plays 69 and the second 68, as the bar lines fall: + +``` +5 4 5 4 5 4 4 4 5 4 4 4 5 4 4 4 69 ticks +5 4 4 4 5 4 4 4 5 4 4 4 5 4 4 4 68 ticks +``` + +Once the exact lengths of a run of patterns add up to whole ticks, the grooves start over: here every +seven patterns, which last 480 ticks. A pass through the whole song lasts the whole number of ticks +nearest its exact length, and a song that repeats replays that pass. + +**A bar's ticks are halved down to its rows.** The bar's beats are cut in two, the larger part first, so +three beats part as two and one. Each part takes the share of the ticks nearest its exact length, held to +what its rows can carry, so every row keeps proportion. Each part is halved again, and a single beat is +cut between its rows the same way. The surplus therefore settles on the strongest positions. Three rows +sharing 23 ticks, for example, play `8 7 8`. + +**The meter restarts at every pattern,** as a tracker's highlights do. A pattern that isn't a whole number +of bars ends on a shorter bar. A beat of 3 rows in a bar of 7, over a 16-row pattern, gives bars of 7, 7 +and 2 rows, with beats of 3, 3 and 1 in the first two. + +**Every player reads the same timing.** In-app playback, rendering, the NSF export, the Bitphase export +and the tracker playback check all ask how long a row lasts by the row's place in the song. Each answer +follows from that place alone, so a player starting from any row reads its length directly. + +## 4. Ticks and the clocks they play on + +Rows are planned in engine ticks, at the song's NES frequency. An instrument's envelope moves once a tick, +and a note starts on a tick, so the tick is the unit a row's length can be counted in. Each way of +playing the song then turns ticks into its own time once: + +- **In-app playback and rendering** turn each tick into audio samples. A tick rarely lasts a whole number + of samples, so the samples are spread the way the ticks are spread over rows: the fraction carries from + tick to tick. +- **The NSF** runs on the console's own call, about 60.1 times a second on NTSC. Its driver adds the song's + rate, measured in calls, to a counter on every call and plays the ticks that fall out. A 30 Hz song + moves on every other call, a 41 Hz song holds about one call in three, and a 300 Hz song moves up to + five ticks a call. Any rate the settings allow therefore plays at its own speed. + +Each clock carries its own remainder, so neither adds up over a song. A bar line the console plays lands +within half a tick of its exact moment plus one call. Planning the rows in ticks also keeps the NSF on the +rhythm the app plays, and its stream holds only engine ticks, so a phrase repeats wherever it is played. + +## 5. The exports + +**The NSF plays the app's ticks.** Its stream holds the ticks the song walk plays, and its driver re-clocks +them onto the console's call (section 4). The stream stores how long each row's phrase lasts beside the +phrase, so a frame whose groove differs from the one before costs a few more bytes than a frame that +repeats it. + +**Bitphase plays the app's rows.** Its song has a speed and no tempo, so the export writes a speed effect +on every row that lasts differently from the row before it. [Tempo as a +groove](../formats/bitphase.md#d-tempo-as-a-groove) has the details. + +**FamiTracker places the longer rows by its own count.** The export writes the project's speed and tempo +as they stand, and FamiTracker carries the rounding from row to row as it plays. Its song keeps the tempo +over time, and the longer rows fall wherever its running count lands. A module's rows can therefore last +a tick more or less than the app's, while every bar stays close to where the tempo puts it. A NES +frequency other than 50 or 60 Hz reaches FamiTracker as its engine speed. + +## 6. How it is checked + +The test suite holds the groove of every combination of tempo, speed, NES frequency and meter it sweeps +to the properties above, across many frames of a song. It measures how far each bar line lands from its +exact moment, checks that every row lasts the floor or the ceiling of its exact length, and checks which +rows of a beat and which beats of a bar carry the surplus. It also follows the bar lines through the +console's call at several rates, and holds what a player spends asking for a row's length to a small +share of what walking the song costs. diff --git a/docs/development/application/playback.md b/docs/development/application/playback.md index 21a91cc0d..11b4e37c0 100644 --- a/docs/development/application/playback.md +++ b/docs/development/application/playback.md @@ -14,7 +14,7 @@ Two terms recur. A **source** is what plays one kind of audio: a reconstruction, 4. **Surfaces describe the target, and the transport decides.** The menu, the toolbar and the keyboard reach identical verbs and report identical state, so a new surface adds another way in to the same behavior. 5. **Listening choices stay out of the document.** What the user chooses to hear is session state, and what the project holds is the whole song. Saving, export, rendering and history read the document, so each of them works on the full song whatever the user is listening to. A render reads the document as it stood when it was asked for: every channel sounding, at unity gain, played through once. 6. **Live state is pulled while sound is produced.** A player reads the settings that shape its sound as it renders, so a change is heard as the render-ahead buffer drains. A listening control therefore takes effect inside the sound already playing. -7. **A row's duration belongs to the song, not to the player.** How long a row lasts follows from the project's tempo and meter together with the row's place in the pattern. It is a function of position: the same row lasts the same time however playback reached it, and a module exported from the song can state the same figures. The integer tick counts the [groove](../../glossary.md#groove) places *are* the tempo, so a render realizes them exactly at every rate it offers. +7. **A row's duration belongs to the song, not to the player.** How long a row lasts follows from the project's tempo and meter together with the row's place in the song. It is a function of position: the same row lasts the same time however playback reached it, and a module exported from the song can state the same figures. The integer tick counts the [groove](../../glossary.md#groove) places *are* the tempo, so a render realizes them exactly at every rate it offers. ## Two kinds of sound @@ -137,7 +137,7 @@ The device holds a release per stream it handed out and invokes it whenever it n | Row mixing, and the mask it pulls while rendering | `RowSynthesizer` (`logic/sequencer/playback/synthesizer/`) | | The values a note starts from and a channel holds between frames | `ChannelPerformance` (`sampletones_core/performance/state.py`) | | How a row's level and transpose reach what a channel sounds | `apply_modifiers` (`sampletones_core/performance/modifiers.py`) | -| How long a row lasts, and how many samples its ticks span | `Groove` and `TickClock` (`sampletones_core/timing/`) | +| How long a row lasts, and how many samples its ticks span | `SongTiming` and `TickClock` (`sampletones_core/timing/`) | | Rendering the song to a file, its passes and its progress | `SongRenderService` (`services/render/`) | The sequencer song is an ordinary intentional source alongside the reconstruction and instruction players. It implements the same protocol and is arbitrated by the same rules. diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 05eb96f00..a50c61684 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -36,12 +36,11 @@ dimension the import starts carrying. * A FamiTracker transpose row moving a note more than fifteen semitones, or a shared pattern's cell that frames reach needing different slides, is written without its slide and reported. A second effect column, or a pattern cloned per frame, would carry it. -* A FamiTracker bend ending before its arpeggio loses the offset it ends on, since the running arpeggio - reloads the period from the note and a halted bend adds nothing. Only instruments a note slide reaches - circle the bend on its last item today. * A FamiTracker module's pulse level can sound a step away from in-app playback. FamiTracker rounds the product of the two levels down and keeps the quietest level where that comes out silent, while the app and Bitphase round it to the nearest step. +* A FamiTracker module places the longer rows of an uneven tempo by FamiTracker's own running count. + A compatibility setting writing a speed effect per row at tempo 150 would make it play the app's groove. ### Workflow diff --git a/docs/development/packages.md b/docs/development/packages.md index a06c36917..a2de65daa 100644 --- a/docs/development/packages.md +++ b/docs/development/packages.md @@ -104,9 +104,10 @@ The driver's assembler and the register trace live in `sampletones_tools/player/ neither. The assembler builds the committed `driver/binary/driver.bin` from the assembly sources beside it. The tests rebuild the sources wherever cc65 is installed and hold the committed image to them. `RegisterTrace` says what the driver is expected to write, call by call, and the emulator tests hold the -assembled driver to it. Exporting reads the assembled binary. The wheel carries the assembly sources -beside it, inside the tools package. [`dependencies.md`](release/dependencies.md) describes the toolchain -the build needs. +assembled driver to it. They run it on the console in `sampletones_tools/console/`, which plays any `.nsf` +the way an NSF player does, loaded whole or in switched banks. Exporting reads the assembled binary. The +wheel carries the assembly sources beside it, inside the tools package. +[`dependencies.md`](release/dependencies.md) describes the toolchain the build needs. --- diff --git a/docs/development/release/dependencies.md b/docs/development/release/dependencies.md index 3270fe166..5398491c6 100644 --- a/docs/development/release/dependencies.md +++ b/docs/development/release/dependencies.md @@ -54,11 +54,11 @@ cc65 is distributed under the zlib license. The link line names our own object f ### Verifying the driver -[py65](https://github.com/mnaberez/py65), a 6502 emulator in the `dev` dependency group, executes the assembled driver against memory that watches the APU's address range. That lets the suite hold the image to what a correct driver writes ([the console player](../player.md)). py65 is a developer dependency, outside both the wheel and the bundles, and its BSD license leaves the project's own terms untouched. +[py65](https://github.com/mnaberez/py65) is a 6502 emulator. The console in `sampletones_tools/console/` runs an `.nsf` on it the way an NSF player does, against memory that watches the APU's address range. That lets the suite hold the image to what a correct driver writes ([the console player](../player.md)). The tools package ships with every install, so py65 is a runtime dependency: the wheel names it and the bundles carry it. Its BSD license leaves the project's own terms untouched, and `THIRD-PARTY-LICENSES.txt` has its text. Listening to a real APU needs [ffmpeg](https://ffmpeg.org/) with the `libgme` demuxer, which is a build option and not a given. `uv run sampletones nsf render` asks the installed ffmpeg which demuxers it has, and names this system's install command before it decodes anything. -CI reaches only py65, because the workflows install the `dev` group and `scripts/system_dependencies.py` has what building and running the application needs. cc65 and ffmpeg stay on the machine of whoever assembles the driver or renders a wave. A workflow that did either would put them in those scripts. The application itself needs neither: an export is written by the package's own code, from the committed `driver.bin`. +CI runs the driver on py65, which arrives with the package's own dependencies, and `scripts/system_dependencies.py` has what building and running the application needs. cc65 and ffmpeg stay on the machine of whoever assembles the driver or renders a wave. A workflow that did either would put them in those scripts. The application itself needs neither: an export is written by the package's own code, from the committed `driver.bin`. ## Linux (standalone executable) diff --git a/docs/development/tooling.md b/docs/development/tooling.md index 5e6ced4d5..c689e492d 100644 --- a/docs/development/tooling.md +++ b/docs/development/tooling.md @@ -63,9 +63,10 @@ build on is refused by name. The Makefile is the developer's index, one line per target. A target names the script that does the work and passes its flag. The `run` and `calibration` targets name the `sampletones` command they start with no -options, and the `tracker-playback` target passes the one input its command needs, the Bitphase -checkout its `BITPHASE` variable names. `install.sh` and `install.bat` at the root exist for the -double-click path and call the same bundle script. +options, and the `tracker-playback` target passes the inputs its command takes: the Bitphase checkout +its `BITPHASE` variable names, the `FamiTracker.exe` its `FAMITRACKER` variable names, and the project +files in `PROJECT`, if any. It runs each target it is given. `install.sh` and `install.bat` at the root +exist for the double-click path and call the same bundle script. **8. A developer command works from what it is given, in every copy of the program.** The wheel and the bundle carry the tools package, so every developer command exists wherever `sampletones` is installed, and diff --git a/docs/formats/bitphase.md b/docs/formats/bitphase.md index 77341648b..f979cd794 100644 --- a/docs/formats/bitphase.md +++ b/docs/formats/bitphase.md @@ -71,8 +71,8 @@ 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), or one `0` where the slice leaves its duty to the channel; 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 | +| `pulseWidth` | 0–3 | 2 | square duty cycle; on the noise channel, the lowest bit selects the short LFSR | the duty-cycle envelope (squares), the short or long mode (noise), or one `0` where the slice leaves its duty to the channel; the triangle writes no macro | +| `toneAdd` | −4096–4095 | 0 | period offset added to the period the note resolves to (squares and triangle), or steps added to the note (noise) | 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 | @@ -179,8 +179,9 @@ An instrument preset has no table, so its pitch movement is the per-tick `toneAd 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. A preset loads into a document that keeps its own tuning, so a preset stays -at concert pitch whatever the reconstruction was tuned at. The noise channel takes its period from the -note, so a preset for it has a flat offset. +at concert pitch whatever the reconstruction was tuned at. On the noise channel the offset moves the +note, which carries the period itself, so a noise preset's offset is the contour step and the bend added +together, in period steps. ### C.4 The bend rides the tone offset @@ -197,37 +198,39 @@ is the `toneAdd` macro, 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, measured from the period the song's own table gives the note | -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. +The squares and the triangle add the offset to the period. The noise channel adds it to the note, which +carries the period itself, so a noise slice's bend is written as it stands: one period step per unit. A +slice that sounds every tick on its own note writes no `toneAdd`, so a document pays only for the bends +it sounds. ## D. Tempo as a groove A Bitphase song has a **speed**, the ticks each row lasts. A _SampleToNES_ project has a tempo and a speed together. The row rate the pair asks for is fractional at most tempi, so the exporter writes it as a -[groove](../glossary.md#groove): whole tick counts, one per row of a pattern, averaging out to that rate, -with the longer rows on the bar and the beat. In-app playback reads the same groove, so a document plays -the rows the sequencer played. For example, at 60 Hz with speed 6 and tempo 210, a 16-row pattern in -common time comes to: +[groove](../glossary.md#groove): whole tick counts, one per row, averaging out to that rate, placed as +[song timing](../concepts/timing.md) places them. In-app playback reads the same timing, so a document +plays the rows the sequencer played. For example, at 60 Hz with speed 6 and tempo 210, a 16-row pattern +in common time lasts 68 4/7 ticks, so the song's first two frames play: ``` -5 4 5 4 5 4 4 4 5 4 4 4 5 4 4 4 69 ticks, a rate of 30/7 per row +5 4 5 4 5 4 4 4 5 4 4 4 5 4 4 4 69 ticks +5 4 4 4 5 4 4 4 5 4 4 4 5 4 4 4 68 ticks ``` -**The groove reaches the engine as a table.** A speed effect that names a table reads one of its entries -per pattern row, and that carries a per-row tick count into a song: +**The groove reaches the engine as speed effects.** A speed effect sets the ticks a row lasts from its +row on, so the exporter writes one wherever a row lasts differently from the row played before it: | Part | What the exporter writes | | --- | --- | -| `initialSpeed` | the ticks the pattern's first row lasts | -| The table | one entry per pattern row, `loop = 0`, taking the id above the last slice table | -| The effect | `S` with `delay = 0` and an empty parameter, naming that table | -| Its place | the first row of the DPCM channel, in every pattern | +| `initialSpeed` | the ticks the song's first row lasts | +| The effect | `S` with `delay = 0`, the row's ticks as its parameter, and `tableIndex = -1` | +| Its place | the DPCM channel, on every row whose length differs from the row before it | -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 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. +The order comes round to its first pattern after the last, so the song's last row counts as the row +before its first. 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 its own effect column free. +Bitphase finds the speed a row it starts playing from lasts by reading back to the last speed effect, so +a song started anywhere plays every row at its length. **A tempo the speed column can state needs no groove.** Where every row lasts alike, as with tempo 150 at 60 Hz where the rate is the speed itself, `initialSpeed` has the tempo whole. The document then has one @@ -332,10 +335,10 @@ is the same cell you would see in the tracker. | Quantity | Bitphase limit | Exporter behavior | | --- | --- | --- | | Values per instrument macro | 1–512 | writes the opening values of a longer dimension, keeps a volume's closing silence, and reports what it left out | -| Rows per table | unbounded | writes the contour, or the groove, whole | -| Effect columns per channel | 1–4 | writes one: the groove trigger on the DPCM channel, the ornament position on a transpose row | +| Rows per table | unbounded | writes the contour whole | +| Effect columns per channel | 1–4 | writes one: the speed effects on the DPCM channel, the ornament position on a transpose row | | 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, counting one a groove takes and the moved tables transpose rows name | +| Tables | the table column holds 1 base-36 digit, so ids 0–34 | raises past 35 tables, counting the moved tables transpose rows name | | Ornament position | the effect parameter is a byte, so steps 0–255 | names a copy of the table opening on a later step | | Note range | the 96-entry tuning table, pitch 24–119 | keeps the song's range, 33–119, raising a lower note only as far as its table's highest step reaching 33 (section E) | | A4 tuning | 220–880 Hz, the range the song settings offer (`src/lib/chips/nes/schema.ts`) | writes the work's tuning, and raises past that range | @@ -343,7 +346,7 @@ is the same cell you would see in the tracker. | Pattern length (rows) | 1–256 | clamps the preview pattern; a project keeps `rows_per_pattern` | | Order positions | unbounded | matches | | Speed | 1–255 | the groove's tick counts, bounded to that range | -| DPCM channel | present | rests, apart from the groove trigger each pattern's first row carries | +| DPCM channel | present | rests, apart from the speed effects the groove sets | A row that names a voice on a channel the voice has no instrument for plays nothing in the song, so the exporter writes a note cut on it and reports the row by its frame, channel and row. The project export @@ -351,10 +354,8 @@ dialog lists those rows. Tables and instruments are numbered together, and each slice takes one of each. The table column is therefore what a wide document reaches first, and the exporter raises an error instead of writing a -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 moved tables transpose -rows name take the ids above the slices and the groove, and a document needing more of them than the -column names is refused the same way. +document whose later voices cannot be named. The moved tables transpose rows name take the ids above the +slices, and a document needing more of them than the column names is refused the same way. **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. diff --git a/docs/formats/famitracker.md b/docs/formats/famitracker.md index 36307c7e2..3e0ccc436 100644 --- a/docs/formats/famitracker.md +++ b/docs/formats/famitracker.md @@ -4,6 +4,8 @@ This document is the reference for how _SampleToNES_ writes and reads FamiTracke write or check an `.fti` instrument file or an `.ftm` module file. It covers the binary layout of both formats (A), the instrument model (B), what an imported `.fti` gives a voice (C), FamiTracker's capacity limits (D) and the memory an instrument takes in the NSF driver (E). +[The tracker playback check](../tools/tracker-playback.md) has FamiTracker export modules to NSF files and +lists every tick they sound differently from the app. The target is **vanilla FamiTracker 0.4.6** (`FILE_VER = 0x0440`). Files written to this specification load in stock FamiTracker and in the 0CC, Dn-FamiTracker and FamiStudio forks. The module is single-chip @@ -142,6 +144,10 @@ Instruments reference the pooled sequences by index, so the module stores each s | pattern length | `int32` | | | order table | `uint8` | for each frame, one pattern index per channel | +The speed and tempo are the project's own. FamiTracker spreads a tempo between two tick counts with its +own running count, so the longer rows of a module fall where that count lands. [Song +timing](../concepts/timing.md#5-the-exports) compares it with the app's placement. + `PATTERNS` payload, per non-empty pattern: | Field | Type | Notes | @@ -205,8 +211,11 @@ that tick, and a hi-pitch item is the same offset counted sixteen dividers at a halts, the same items accumulate on the running period. _SampleToNES_ writes and reads a bend as the per-tick offset. The writer therefore keeps an arpeggio -running for as long as the bend. An instrument with a bend and no arpeggio gets one holding a single zero -at loop point 0. A shorter arpeggio is extended to the bend's length by holding its final note. What one +running for as long as the bend acts. A bend that repeats, or ends on an offset it then holds, acts for as +long as the note sounds: the arpeggio and the bend both circle on their last items, so the tracker adds the +held offset on every tick. A bend ending on no offset acts until its last item. An arpeggio shorter than +that is extended to the bend's length by holding its final note. An instrument with a bend and no +arpeggio gets one holding a single zero at loop point 0. What one step is worth follows the note it bends: under a cent at the lowest notes, widening to a whole semitone at the highest, where the divider grid is already coarser than the note grid. @@ -239,8 +248,8 @@ export](bitphase.md#f-bitphase-capacity-limits) shortens a dimension by the same with a single zero: a disabled slot leaves that dimension to the channel, while a one-item sequence sets the value once and holds it. FamiTracker starts every note of a disabled slot where _SampleToNES_ starts one: the instrument volume full, so the note plays at the volume column; the note unmoved by an arpeggio -or a bend; and the channel's default duty. A `Vxx` effect sets that duty, and it stays at 0 because an -export leaves every effect column empty. A dimension is empty when the reconstruction records it as one +or a bend; and the channel's default duty. A `Vxx` effect sets that duty, and an export +writes none, so it stays at 0. A dimension is empty when the reconstruction records it as one the channel governs. Clearing the envelope in the instruments panel produces that state (see [Reconstructions](reconstructions.md)). @@ -256,7 +265,9 @@ sequences carry across directly. A conversion that bent no note records both ben channel governs, so they reach the file as disabled slots. The DPCM key-assignment table is always empty. An [instrument](../glossary.md#instrument) written by hand is one set of envelopes that every channel -reads, as in FamiTracker itself. It becomes a single instrument, however many channels play it. Each +reads, as in FamiTracker itself. It becomes a single instrument, however many channels play it. The noise +channel reads it the way FamiTracker and Bitphase do: the duty item's lowest bit selects the short mode, +and each pitch item moves the period one step, as an arpeggio step does. Each dimension is written at the length it was typed at and has its own loop point, so a tracker that advances every sequence on its own counter sounds it the way the engine here plays it. Every channel that names the instrument reaches that one instrument, each against the initial pitch it reads: its note on the tonal diff --git a/docs/glossary.md b/docs/glossary.md index f4338b47e..4101ad8c9 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -277,9 +277,10 @@ each. A tempo counts beats: ### Groove The number of ticks each row of a pattern lasts. A row lasts a whole number of ticks, so a tempo between -two counts is played by varying the count from row to row. The meter places the longer rows on the bar -first, then on the beat, then inside the beat. Playback reads the groove by the row's position in the -pattern, so the pattern's first row starts it afresh. +two counts is played by varying the count from row to row. Every bar starts on the tick nearest its exact +moment, and the meter places the longer rows on the strongest positions of the bar and of each beat. +A frame plays the groove its place in the song gives it, so two frames of one pattern can differ by a +tick. [Song timing](concepts/timing.md) explains the rules. ### Order diff --git a/docs/guide/command-line.md b/docs/guide/command-line.md index 4db894173..58abf4008 100644 --- a/docs/guide/command-line.md +++ b/docs/guide/command-line.md @@ -35,9 +35,9 @@ They run from a copy of the source code. Add `--help` to one to see what it does `sampletones calibration` measures how well the app reconstructs a set of reference sounds. [Calibration](../tools/calibration.md) explains how to run it and read the results. -`sampletones tracker-playback` checks that songs exported to a tracker play there as they play in the -app. [Tracker playback check](../tools/tracker-playback.md) explains what it needs and how to read its -report. +`sampletones tracker-playback` checks that your songs, exported to FamiTracker or Bitphase, play there as +they play in the app. [Tracker playback check](../tools/tracker-playback.md) explains what it needs and how +to read its report. ## Options diff --git a/docs/guide/reconstruction.md b/docs/guide/reconstruction.md index 0bbdfefaa..03531e859 100644 --- a/docs/guide/reconstruction.md +++ b/docs/guide/reconstruction.md @@ -90,6 +90,9 @@ in the **Voices** list and choose **Edit**. For an instrument, the panel shows **Audition** in place of the pitch steppers. Choose **Pulse**, **Triangle** or **Noise**, and the note keys play the instrument on that channel. +The noise channel plays an instrument the way FamiTracker and Bitphase do. Each pitch step moves the noise +one step, like an arpeggio step, and an odd duty cycle value plays the short mode. + ## Exporting You export a reconstruction from the **Reconstruction** menu: diff --git a/docs/guide/sequencer.md b/docs/guide/sequencer.md index ecf7d489b..64bdc1733 100644 --- a/docs/guide/sequencer.md +++ b/docs/guide/sequencer.md @@ -232,9 +232,11 @@ includes them. It also sets the [meter](../glossary.md#metric-highlight): The tracker marks the first row of each beat and each bar. The defaults, 4 and 16, give four beats of four rows in a bar. For waltz time, set **Second highlight** to 12, which gives three beats. -The tempo counts beats, so the meter also changes how fast the song feels. [Tempo as a -groove](../formats/bitphase.md#d-tempo-as-a-groove) explains how the app spreads a tempo over the -rows. +The tempo counts beats, so the meter also changes how fast the song feels. A row lasts a whole number of +ticks, so at most tempi some rows last a tick longer than others. The longer rows fall on the strong beats. +A row can't be shorter than one tick: at a tempo asking for shorter rows, every row lasts one tick and the +song plays slower, as it does in FamiTracker and Bitphase. [Song timing](../concepts/timing.md) explains +how the app spreads a tempo over the rows. ## Exporting the song diff --git a/docs/index.md b/docs/index.md index cba20f4e7..f8a39a680 100644 --- a/docs/index.md +++ b/docs/index.md @@ -32,6 +32,7 @@ without the source code. - [Stems reconstruction](concepts/stems.md) — how the channels are shared between several stems. - [Instruction library](concepts/instruction-library.md) — the catalog of NES sounds the search draws from. - [Song compression](concepts/compression.md) — how a song fits into the space an NES program has for it. +- [Song timing](concepts/timing.md) — how a tempo becomes the ticks each row lasts. - [Project](concepts/project.md) — a song and the reconstructions it is built from. ## Tools diff --git a/docs/tools/tracker-playback.md b/docs/tools/tracker-playback.md index 9a2a82c19..302e73fcc 100644 --- a/docs/tools/tracker-playback.md +++ b/docs/tools/tracker-playback.md @@ -1,17 +1,21 @@ # Tracker playback check The tracker playback check tells you whether a song exported to a tracker plays there the way -_SampleToNES_ plays it. It exports a set of small projects, plays each exported file with the tracker's -own playback code, and compares what the sound chip plays on every channel and every engine tick with -what _SampleToNES_ plays. Then it writes a report of each difference. +_SampleToNES_ plays it. It exports your projects, or a set of small projects that comes with +_SampleToNES_, and plays each exported file with the tracker's own playback code. It compares what the +sound chip plays on every channel and every engine tick with what _SampleToNES_ plays. Then it writes a +report of each difference. -Each tracker the check plays through is a *target*. It has one today: +Each tracker the check plays through is a *target*: - `bitphase` exports a `.btp` document and plays it with the engine of a copy of the [Bitphase](../glossary.md#bitphase) source code. +- `famitracker` exports an `.ftm` module, has [FamiTracker](../glossary.md#famitracker) export it to an + `.nsf` file, and plays that file the way a NES does. Use it to: +- Check that your song plays in the tracker the way you wrote it. - Check an export after changing it. - Check an export against a newer version of the tracker. - Find the tick, the row and the channel where the tracker plays a song differently. @@ -26,21 +30,38 @@ For the `bitphase` target: The check reads that copy and leaves it as it is. +For the `famitracker` target: + +- `FamiTracker.exe`, version 0.4.6, from [famitracker.com](http://famitracker.com). +- On Linux and macOS, [Wine](https://www.winehq.org), so that the `wine` program runs in a terminal. On + Debian and Ubuntu, that is `sudo apt install wine`. On macOS, `brew install --cask wine-stable`. + +FamiTracker exports the file without opening a window. + ## Run it -From a copy of the source code: +In an installed copy, name the target and what it needs: ``` -make tracker-playback BITPHASE=path/to/bitphase +sampletones tracker-playback bitphase --checkout path/to/bitphase +sampletones tracker-playback famitracker --executable path/to/FamiTracker.exe ``` -In an installed copy: +To check your own song, add `--project` with its project file. Repeat it to check several songs in one +run: ``` -sampletones tracker-playback bitphase --checkout path/to/bitphase +sampletones tracker-playback famitracker --executable path/to/FamiTracker.exe --project my-song.stp ``` -With no other options, the run plays the corpus that comes with _SampleToNES_: +From a copy of the source code, `make tracker-playback` runs the targets you give it. `PROJECT` takes +one or more project files, separated by spaces: + +``` +make tracker-playback BITPHASE=path/to/bitphase FAMITRACKER=path/to/FamiTracker.exe PROJECT=my-song.stp +``` + +Without `--project`, the run plays the corpus that comes with _SampleToNES_: - Small projects that each exercise one thing a song can do. They cover notes on every channel, volume rows, transpose rows, note-offs, hand-written instruments, samples with arpeggios and bends, @@ -64,11 +85,21 @@ to the report. For the `bitphase` target, `documents/` has `.btp`, ready to open in Bitphase, and `.json`, every write Bitphase's engine made to the sound chip, tick by tick. +For the `famitracker` target, `documents/` has: + +- `.ftm`, ready to open in FamiTracker. +- `.nsf`, the file FamiTracker exported, row markers included (see below). +- `.json`, every write the NSF made to the sound chip, tick by tick. + +Your projects are named after their files. When two files have the same name, the second one's files +get `-2` after the name. + ## Reading the report The report opens with a table of the projects. Each has the ticks each side plays and its verdict. -A section per project follows. It says what the project exercises, and what the export reported -leaving out, such as an instrument it shortened. A table then lists each difference. A line names: +A section per project follows. It says which file the project came from, or what a corpus project +exercises. It also says what the export reported leaving out, such as an instrument it shortened. A +table then lists each difference. A line names: - The channel. - What differs. `audible` means the channel sounds on one side only. Otherwise the difference is in @@ -89,9 +120,16 @@ in `volume`. A channel that is silent on both sides counts as alike. The report also says when the two sides place a tick on different rows, or play a different number of ticks. +When your tempo makes rows last unequal ticks, FamiTracker spreads those ticks its own way, so its rows +start on other ticks than in _SampleToNES_. The report then shows the rows parting and differences that +follow from it. + ## Options - `bitphase --checkout `: the copy of the Bitphase source code. The `bitphase` target needs it. +- `famitracker --executable `: `FamiTracker.exe`. The `famitracker` target needs it. +- `--project `: a project file to check. Repeat it to check several. Without it, the run plays + the corpus. - `--output ` or `-o `: where the run writes. Without it, the run writes into the timestamped folder described above. @@ -99,9 +137,21 @@ place a tick on different rows, or play a different number of ticks. Each project is exported by the same code the app's export runs. The target then plays the file with the tracker's own playback code and records every write the tracker makes to the sound chip's -registers on each tick. For `bitphase`, a script that comes with _SampleToNES_ plays the document -through the Bitphase copy's own loader and renderer, and records each write its engine makes to the -chip. +registers on each tick. + +- For `bitphase`, a script that comes with _SampleToNES_ plays the document through the Bitphase + copy's own loader and renderer, and records each write its engine makes to the chip. +- For `famitracker`, FamiTracker exports the module to an NSF from its command line. The NSF holds + FamiTracker's own sound driver, the program a NES runs to play the song. The check runs it on an + emulated NES processor, the way an NSF player does, and records each write the driver makes to the + chip. + +An NSF doesn't say which row it plays. So the module FamiTracker exports has a marker on every row of +its DPCM channel, which _SampleToNES_ leaves empty. The marker sets the DPCM channel's output level to +one of a few low values, and the check reads it to know where each row starts. The other channels' +registers stay as the export wrote them. In an NSF player, the markers make the song sound a little +different: the level shifts the triangle and noise slightly, and each row clicks faintly. The `.ftm` +file the run keeps is the export as the app writes it, without the markers. The same project is played through the song walk the sequencer and the NSF export share, and turned into the register writes the NSF player makes. Both sides are then read the same way. The check keeps diff --git a/pyproject.toml b/pyproject.toml index eeb76b8fe..01b240ac7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,7 @@ dependencies = [ "librosa>=0.11,<0.12", "numpy>=2.0,<3", "pebble>=5.0,<6", + "py65>=1.2,<2", "pydantic>=2.9,<3", "pyaudio>=0.2.14,<0.3", "rich>=13.0,<16", @@ -83,7 +84,6 @@ dev = [ "isort==8.0.1", "mypy==2.1.0", "pre-commit==4.6.0", - "py65==1.2.0", "pylint==4.0.6", "pylint-pydantic==0.4.1", "pytest==9.1.1", diff --git a/src/sampletones_application/logic/export/nsf/source/project.py b/src/sampletones_application/logic/export/nsf/source/project.py index 8142a8ee2..42636febc 100644 --- a/src/sampletones_application/logic/export/nsf/source/project.py +++ b/src/sampletones_application/logic/export/nsf/source/project.py @@ -9,7 +9,7 @@ from sampletones_core.exports.backend import ExportBackend from sampletones_core.exports.request import ProjectExport from sampletones_core.exports.scope import ExportScope -from sampletones_core.timing import SongTiming +from sampletones_core.timing import SONG_TICK_BOUNDS, SongTiming from sampletones_player.compression.scheme import offered_schemes from sampletones_player.export.program import NSFProgram from sampletones_shared.utils.system.paths import get_directory @@ -49,11 +49,11 @@ def program(self) -> NSFProgram: def ticks(self, channels: AbstractSet[ChannelName]) -> int: # pylint: disable=unused-argument """The ticks the whole order plays for, which every channel shares.""" project = self.request.project - return SongTiming.from_project(project).frame_tick(project.song.order_length()) + return SongTiming.from_project(project, bounds=SONG_TICK_BOUNDS).frame_tick(project.song.order_length()) def frame_tick(self, frame: int) -> int: - """The tick order frame ``frame`` starts on, under the groove the song plays at.""" - return SongTiming.from_project(self.request.project).frame_tick(frame) + """The tick order frame ``frame`` starts on, under the timing the song plays at.""" + return SongTiming.from_project(self.request.project, bounds=SONG_TICK_BOUNDS).frame_tick(frame) def proposed_directory(self, session_manager: SessionManager) -> Path: """The folder the project was last opened or saved in.""" diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/length.py b/src/sampletones_application/logic/sequencer/playback/synthesizer/length.py index 96b90aa5c..cf3272322 100644 --- a/src/sampletones_application/logic/sequencer/playback/synthesizer/length.py +++ b/src/sampletones_application/logic/sequencer/playback/synthesizer/length.py @@ -2,7 +2,7 @@ from typing import Self from sampletones_core.project import Project -from sampletones_core.timing import SongTiming +from sampletones_core.timing import SONG_TICK_BOUNDS, SongTiming from .rates import EngineRates @@ -32,7 +32,7 @@ def measure(cls, project: Project, *, sample_rate: int) -> Self: total the walk through the song reaches. """ return cls( - ticks=SongTiming.from_project(project).frame_tick(project.song.order_length()), + ticks=SongTiming.from_project(project, bounds=SONG_TICK_BOUNDS).frame_tick(project.song.order_length()), rates=EngineRates.from_project(project, sample_rate), ) diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/synthesizer.py b/src/sampletones_application/logic/sequencer/playback/synthesizer/synthesizer.py index 5f2f51063..9e8527ff6 100644 --- a/src/sampletones_application/logic/sequencer/playback/synthesizer/synthesizer.py +++ b/src/sampletones_application/logic/sequencer/playback/synthesizer/synthesizer.py @@ -11,7 +11,7 @@ from sampletones_core.project import Project from sampletones_core.project.song import Song from sampletones_core.project.song_position import SongPosition -from sampletones_core.timing import Groove, SongTiming +from sampletones_core.timing import SONG_TICK_BOUNDS, SongTiming from .bank import ChannelBank from .frames import RowFrames @@ -65,8 +65,7 @@ def __init__( self._active_channels = active_channels self._sample_rate = sample_rate self._position = SongPosition() - self._timing: SongTiming = SongTiming.from_project(project_source.project) - self._groove: Groove = self._timing.groove() + self._timing: SongTiming = SongTiming.from_project(project_source.project, bounds=SONG_TICK_BOUNDS) self._channels: Optional[ChannelBank] = None self._elapsed_ticks: int = 0 @@ -97,12 +96,12 @@ def render_row(self) -> Tuple[np.ndarray, SongPosition]: song = project.song self._position.wrap_overflow(song.rows_per_pattern) channels = self._bank() - self._ensure_groove(project) + self._follow_timing(project) frames = RowFrames.from_clock( channels.clock, elapsed_ticks=self._elapsed_ticks, - ticks=self._groove.ticks[self._position.row_index], + ticks=self._timing.row_ticks(self._position.order_position, self._position.row_index), ) position_before = replace(self._position) @@ -146,20 +145,18 @@ def _current_rates(self) -> EngineRates: self._sample_rate(), ) - def _ensure_groove(self, project: Project) -> None: - """Rebuilds the groove when the row rate or the meter it is spread over changes. + def _follow_timing(self, project: Project) -> None: + """Takes up the project's timing when the row rate or the meter it is spread over changes. - An engine that holds a row for a whole number of ticks reaches a fractional row rate by - varying that number from row to row, and the groove is where those counts are decided. - Rebuilding only on a timing edit keeps a tempo change immediate while the distribution - itself, which spans a whole pattern, is computed once. + The timing answers how long a row lasts from the row's place in the song, and it keeps the + bar plans it has computed. Replacing it only on a timing edit keeps a tempo change heard + from the next row on, while the plans of an unchanged timing are computed once. """ - timing = SongTiming.from_project(project) + timing = SongTiming.from_project(project, bounds=SONG_TICK_BOUNDS) if timing == self._timing: return self._timing = timing - self._groove = timing.groove() def _mix_channels( self, diff --git a/src/sampletones_core/exporters/implementation/noise.py b/src/sampletones_core/exporters/implementation/noise.py index 5de6b8175..dce76448f 100644 --- a/src/sampletones_core/exporters/implementation/noise.py +++ b/src/sampletones_core/exporters/implementation/noise.py @@ -1,7 +1,8 @@ -from typing import ClassVar, Dict, List, Tuple, Union +from typing import ClassVar, Dict, Final, List, Tuple, Union from sampletones_core.constants.enums import FeatureKey from sampletones_core.constants.general import NUM_PERIODS +from sampletones_core.features.spec import CHANNEL_FEATURE_DEFAULTS from sampletones_core.generators import GeneratorTypeUnion, NoiseGenerator from sampletones_core.instructions import ( InstructionFields, @@ -11,11 +12,14 @@ from ..exporter import Exporter +SHORT_MODE_BIT: Final[int] = 0x01 + class NoiseExporter(Exporter[NoiseInstruction]): _ATTRIBUTE_MAP: ClassVar[Dict[FeatureKey, InstructionFields]] = { FeatureKey.VOLUME: "volume", FeatureKey.ARPEGGIO: "period", + FeatureKey.PITCH: "detune", FeatureKey.DUTY_CYCLE: "short", } @@ -93,11 +97,29 @@ def _features_dictionary_to_instruction( dictionary: Dict[str, Union[bool, int]], initial_pitch: int, ) -> NoiseInstruction: + """Builds one noise frame from a row of feature values. + + The arpeggio and the bend each move the period one step per unit, around the sixteen + periods, and the duty cycle's lowest bit selects the short mode. That is how FamiTracker + and Bitphase read an instrument on noise, and a reconstruction's noise channel, writing no + bend and a mode of 0 or 1, reads the same either way. + + Args: + dictionary: The per-attribute values for one frame. + initial_pitch: The period the arpeggio and the bend are measured against. + + Returns: + NoiseInstruction: The frame. + """ + steps = dictionary[cls._ATTRIBUTE_MAP[FeatureKey.ARPEGGIO]] + dictionary.get( + cls._ATTRIBUTE_MAP[FeatureKey.PITCH], + CHANNEL_FEATURE_DEFAULTS[FeatureKey.PITCH], + ) return NoiseInstruction( on=cls._infer_instruction_on(dictionary), - period=int((initial_pitch + dictionary[cls._ATTRIBUTE_MAP[FeatureKey.ARPEGGIO]]) % NUM_PERIODS), + period=int((initial_pitch + steps) % NUM_PERIODS), volume=int(dictionary[cls._ATTRIBUTE_MAP[FeatureKey.VOLUME]]), - short=bool(dictionary[cls._ATTRIBUTE_MAP[FeatureKey.DUTY_CYCLE]]), + short=bool(int(dictionary[cls._ATTRIBUTE_MAP[FeatureKey.DUTY_CYCLE]]) & SHORT_MODE_BIT), ) @classmethod diff --git a/src/sampletones_core/features/spec.py b/src/sampletones_core/features/spec.py index f71aa45ba..8ea22f4eb 100644 --- a/src/sampletones_core/features/spec.py +++ b/src/sampletones_core/features/spec.py @@ -72,6 +72,11 @@ class FeatureRange: } +INSTRUMENT_ONLY_READINGS: Final[Dict[GeneratorName, FrozenSet[FeatureKey]]] = { + GeneratorName.NOISE: frozenset((FeatureKey.PITCH,)), +} + + CHANNEL_GENERATOR_KIND: Final[Dict[ChannelName, GeneratorName]] = { ChannelName.PULSE1: GeneratorName.PULSE, ChannelName.PULSE2: GeneratorName.PULSE, @@ -220,3 +225,22 @@ def feature_range( def supports(kind: GeneratorName, feature: FeatureKey) -> bool: return feature in GENERATOR_FEATURE_RANGES[kind] + + +def reads_from_instrument(kind: GeneratorName, feature: FeatureKey) -> bool: + """Whether a channel of this kind reads a dimension of an instrument written by hand. + + A channel reads every dimension its generator offers. The noise channel also reads an + instrument's pitch envelope: each unit moves the period one step, the way an arpeggio step + does, which is how FamiTracker and Bitphase play a bend on noise. A hi-pitch unit moves the + period sixteen steps, a whole turn of the sixteen periods, so the noise channel hears nothing + of it. + + Args: + kind: The channel's generator. + feature: The instrument's dimension. + + Returns: + bool: Whether the channel reads that dimension. + """ + return supports(kind, feature) or feature in INSTRUMENT_ONLY_READINGS.get(kind, frozenset()) diff --git a/src/sampletones_core/formats/binary.py b/src/sampletones_core/formats/binary.py index a7b0bf7f5..cbfc4f97b 100644 --- a/src/sampletones_core/formats/binary.py +++ b/src/sampletones_core/formats/binary.py @@ -87,12 +87,23 @@ def read_bytes(self, count: int) -> bytes: self._offset += count return chunk + def skip(self, count: int) -> None: + """Moves past the next ``count`` bytes, for a field the reader has no use for. + + Raises: + TruncatedDataError: If the buffer holds fewer than ``count`` bytes from here. + """ + self.read_bytes(count) + def read_uint8(self) -> int: return self._read(" int: return self._read(" int: + return self._read(" int: return self._read(" SliceVoice: """Numbers one channel slice and packages it as an instrument-and-table pair. Instruments and tables are numbered alike, so a pattern cell names the same position - in both columns. The document states how far the table numbering reaches, since a song - that carries a groove holds one table of its own above the slices. + in both columns. Raises: ValueError: If the position runs past what a pattern column can name, or past the - table ids the document leaves to its slices. + table ids a document holds. """ number = index + MIN_INSTRUMENT_ID if number > MAX_INSTRUMENT_ID: raise ValueError(f"Document exceeds the Bitphase limit of {MAX_INSTRUMENT_ID} instruments") table_id = index + MIN_TABLE_ID - if table_id > maximum_table_id: - raise ValueError(f"Document holds room for {maximum_table_id + 1} slice tables") + if table_id > MAX_TABLE_ID: + raise ValueError(f"Document holds room for {MAX_TABLE_ID + 1} slice tables") return SliceVoice( number=number, @@ -260,7 +255,6 @@ def sample_to_bitphase(request: SampleExport) -> BitphaseProject: instrument.channel, tuning_table=tuning_table, ), - maximum_table_id=MAX_TABLE_ID, ) for index, instrument in enumerate(request.instruments) ] @@ -314,7 +308,6 @@ def instrument_to_bitphase(request: InstrumentExport) -> BitphaseProject: def _build_voice_table( project: Project, *, - maximum_table_id: int, tuning_table: Tuple[int, ...], ) -> Tuple[ List[SliceVoice], @@ -342,7 +335,6 @@ def _build_voice_table( voice_slice.channel, voice_slice.features.initial_pitch, envelopes, - maximum_table_id=maximum_table_id, ) voices.append(voice) by_reference[voice_slice.key] = voice @@ -447,107 +439,105 @@ def _frame_rows(places: FrozenSet[RowPlace], position: int) -> FrozenSet[int]: return frozenset(place.row_index for place in places if place.order_position == position) -def _project_groove(project: Project) -> Groove: - """Spreads the tempo a project states across the rows of one pattern. - - A Bitphase song holds a speed alone, so the fractional row rate a tempo asks for is - carried by a groove: whole tick counts that vary from row to row and average out to the - rate, placed by the meter so the longer rows fall on the bar and the beat. The engine's - own speed range bounds them, and the groove's mean states the rate it reached. - """ - settings = project.settings - return calculate_groove( - RowRate.from_settings(settings), - Meter.from_settings(settings, rows=project.song.rows_per_pattern), - minimum_ticks=MIN_INITIAL_SPEED, - maximum_ticks=MAX_INITIAL_SPEED, - ) - - -def _maximum_slice_table_id(groove: Groove) -> int: - """The last table id the document leaves to its slices. +def _project_timing(project: Project) -> SongTiming: + """How many ticks every row of the project's song lasts, held within Bitphase's speed range. - A groove whose rows differ occupies the table above the last slice, so the slices reach - one id less far; a groove whose rows last alike is carried by the song's initial speed - and leaves the whole column to them. + A Bitphase song holds a speed alone, so the fractional row rate a tempo asks for is carried by + rows whose tick counts vary, placed the way in-app playback places them. The engine's own speed + range bounds them. """ - if groove.is_uniform: - return MAX_TABLE_ID + return SongTiming.from_project(project, bounds=BITPHASE_TICK_BOUNDS) - return MAX_TABLE_ID - GROOVE_TABLE_COUNT +def _speed_effect(speed: int) -> EffectCell: + """Sets the speed, the ticks a row lasts, from the row carrying it on. -def _groove_table(groove: Groove, table_id: int) -> BitphaseTable: - """Writes the groove as the table a speed effect reads one entry per pattern row from.""" - return BitphaseTable( - id=table_id, - rows=groove.ticks, - loop=LOOP_FROM_START, - name=GROOVE_TABLE_NAME, - ) - - -def _speed_effect(table_id: int) -> EffectCell: - """Names the table a row takes its own duration from. - - The parameter states a speed directly where an effect carries no table, so an effect - that names one leaves it empty; the delay stays at zero, which is what Bitphase reads - on a speed effect. + The delay stays at zero, which is what Bitphase reads on a speed effect, and the parameter + states the speed itself. """ return EffectCell( effect=int(EffectId.SPEED), delay=SPEED_EFFECT_DELAY, - parameter=NO_EFFECT_PARAMETER, - table_index=table_id, + parameter=speed, ) -def _groove_channel_rows(length: int, table_id: int) -> List[BitphaseRow]: - """Rests a channel for a whole pattern beyond the groove trigger its first row carries. +def _speed_rows( + timing: SongTiming, + position: int, + frames: int, + length: int, +) -> Optional[List[BitphaseRow]]: + """Carries the speed changes one frame's rows make, on a channel that otherwise rests. + + A row whose length differs from the row played before it states its own speed. The order + returns to its first frame after the last, so the song's first row follows the last one, and + the song's initial speed covers the first pass. A speed effect applies from whichever channel + holds it, so the speeds ride the silent DPCM channel and every sounding channel keeps its own + effect column. Bitphase finds the speed of a row it starts playing from by reading back to the + last speed effect, so a song started anywhere plays every row at its length. + + Args: + timing: How many ticks every row of the song lasts. + position: The order frame the rows belong to. + frames: How many frames the order plays. + length: The rows a pattern holds. - A speed effect applies from whichever channel holds it, so the groove rides the silent - DPCM channel and leaves every sounding channel its own effect column. The table then - advances one entry per row from where the trigger placed it, and triggering it again on - each pattern's first row keeps every row on the entry that describes it. + Returns: + Optional[List[BitphaseRow]]: The channel's rows, or ``None`` where no row of the frame changes + the speed. """ rows = [BitphaseRow() for _ in range(length)] - rows[GROOVE_TRIGGER_ROW] = BitphaseRow(effects=(_speed_effect(table_id),)) - return rows + changed = False + for row_index, ticks in enumerate(timing.groove(position).ticks): + if ticks != _previous_row_ticks(timing, position, row_index, frames=frames, length=length): + rows[row_index] = BitphaseRow(effects=(_speed_effect(ticks),)) + changed = True + + return rows if changed else None + + +def _previous_row_ticks( + timing: SongTiming, + position: int, + row_index: int, + *, + frames: int, + length: int, +) -> int: + """The ticks the row played just before a row lasts, the song's last row before its first.""" + if row_index != FIRST_ROW: + return timing.row_ticks(position, row_index - 1) + + previous_frame = position - 1 if position != FIRST_FRAME else frames - 1 + return timing.row_ticks(previous_frame, length - 1) def _document_tables( voices: Sequence[SliceVoice], - groove_table: Optional[BitphaseTable], transposes: TransposePlan, ) -> Tuple[BitphaseTable, ...]: - """Gathers the tables a document holds: one per slice, the groove where it takes one, then the moved tables.""" - tables = tuple(voice.table for voice in voices) - if groove_table is not None: - tables += (groove_table,) - - return tables + transposes.tables + """Gathers the tables a document holds: one per slice, then the moved tables.""" + return tuple(voice.table for voice in voices) + transposes.tables -def _moved_table_id(voices: Sequence[SliceVoice], groove_table: Optional[BitphaseTable]) -> int: - """The id the first table a transpose row moves a note to takes, above the slices and the groove.""" - if groove_table is None: - return len(voices) + MIN_TABLE_ID - - return groove_table.id + GROOVE_TABLE_COUNT +def _moved_table_id(voices: Sequence[SliceVoice]) -> int: + """The id the first table a transpose row moves a note to takes, above the slices.""" + return len(voices) + MIN_TABLE_ID def _project_patterns( project: Project, voices: SliceVoiceTable, - groove_table: Optional[BitphaseTable], + timing: SongTiming, transposes: TransposePlan, ) -> Tuple[BitphasePattern, ...]: """Flattens the song's per-channel arrangement into whole-pattern order positions. A SampleToNES order frame points every channel at its own pattern, where a Bitphase order position names one pattern that spans all channels, so each frame becomes a - pattern of its own carrying that frame's channels side by side. Every pattern triggers - the groove table it is given, so the tempo holds wherever the order jumps. The order plays + pattern of its own carrying that frame's channels side by side, with the speed changes its + rows make beside them, so every row lasts what the song's timing gives it. The order plays the frames in turn and returns to the first, which is the walk :func:`full_level_notes` follows to find the notes writing the full level. Each frame is a pattern of its own, so a transpose row writes the cell the frame reaching it needs. @@ -559,11 +549,9 @@ def _project_patterns( for position, frame in enumerate(song.order): channel_rows = _empty_channels(length) - if groove_table is not None: - channel_rows[int(GROOVE_CHANNEL)] = _groove_channel_rows( - length, - groove_table.id, - ) + speeds = _speed_rows(timing, position, song.order_length(), length) + if speeds is not None: + channel_rows[int(GROOVE_CHANNEL)] = speeds for channel_name in ChannelName.items(): index = frame.get(channel_name) @@ -597,8 +585,8 @@ def project_to_bitphase(project: Project) -> BitphaseProject: def build_bitphase(project: Project) -> BuiltDocument[BitphaseProject]: """Maps a project onto the Bitphase document IR and lists what it had to leave out. - The song carries the project's tempo as a groove, which is the initial speed on its own - where every row lasts alike and a table the patterns trigger where the rows differ. It plays + The song carries the project's tempo in its row lengths: the initial speed where every row lasts + alike, and a speed effect on every row whose length differs from the row before it. It plays at the tuning the project's samples were reconstructed at, and at concert pitch where the project holds no sample. A row naming a voice on a channel the voice has no instrument for plays nothing in the song, so the document holds a note cut there and the row is listed @@ -620,27 +608,18 @@ def build_bitphase(project: Project) -> BuiltDocument[BitphaseProject]: """ a4_tuning = concert_frequency(tuning_from_project(project)) tuning_table = generate_tuning_table(DEFAULT_CPU_FREQUENCY, a4_tuning=a4_tuning) - groove = _project_groove(project) + timing = _project_timing(project) voices, by_reference, truncation = _build_voice_table( project, - maximum_table_id=_maximum_slice_table_id(groove), tuning_table=tuning_table, ) - groove_table = ( - None - if groove.is_uniform - else _groove_table( - groove, - len(voices) + MIN_TABLE_ID, - ) - ) transposes = TransposePlan.build( project.song, by_reference, - groove, - first_table_id=_moved_table_id(voices, groove_table), + timing, + first_table_id=_moved_table_id(voices), ) - patterns = _project_patterns(project, by_reference, groove_table, transposes) + patterns = _project_patterns(project, by_reference, timing, transposes) settings = project.settings info = project.info @@ -651,7 +630,7 @@ def build_bitphase(project: Project) -> BuiltDocument[BitphaseProject]: songs=( _build_song( patterns, - speed=groove.ticks[GROOVE_TRIGGER_ROW], + speed=timing.row_ticks(FIRST_FRAME, FIRST_ROW), nes_frequency=settings.nes_frequency, pattern_length=project.song.rows_per_pattern, a4_tuning=a4_tuning, @@ -659,7 +638,7 @@ def build_bitphase(project: Project) -> BuiltDocument[BitphaseProject]: ), ), pattern_order=tuple(pattern.id for pattern in patterns), - tables=_document_tables(voices, groove_table, transposes), + tables=_document_tables(voices, transposes), instruments=tuple(voice.instrument for voice in voices), ), skipped_rows=find_skipped_rows(project.song, by_reference), diff --git a/src/sampletones_core/formats/bitphase/envelopes.py b/src/sampletones_core/formats/bitphase/envelopes.py index 7b209a10d..8fd4e135c 100644 --- a/src/sampletones_core/formats/bitphase/envelopes.py +++ b/src/sampletones_core/formats/bitphase/envelopes.py @@ -50,14 +50,14 @@ class ChannelEnvelopes: def _pulse_width(channel: ChannelName, duty_cycle: int) -> int: """Reads a duty-cycle item as the field the channel uses it for. - A square channel takes it as the duty itself; the noise channel takes any nonzero - value as its short LFSR mode; the triangle channel plays one fixed waveform. + A square channel takes it as the duty itself; the noise channel takes its lowest bit as the + short LFSR mode; the triangle channel plays one fixed waveform. """ match channel: case ChannelName.PULSE1 | ChannelName.PULSE2: return duty_cycle case ChannelName.NOISE: - return NOISE_MODE_SHORT if duty_cycle else NOISE_MODE_LONG + return NOISE_MODE_SHORT if duty_cycle & NOISE_MODE_SHORT else NOISE_MODE_LONG case ChannelName.TRIANGLE: return FLAT_PULSE_WIDTH @@ -136,14 +136,37 @@ def _macro_bag( if channel != ChannelName.TRIANGLE: macros[NesMacroField.PULSE_WIDTH] = macro(_waveform_envelope(features, channel)) - if channel in TONE_CHANNELS: - bend = _bend_envelope(features, contour, tuning_table=tuning_table) - if bend.written: - macros[NesMacroField.TONE_ADD] = macro(bend) + bend = ( + _bend_envelope(features, contour, tuning_table=tuning_table) + if channel in TONE_CHANNELS + else noise_bend(features) + ) + if bend.written: + macros[NesMacroField.TONE_ADD] = macro(bend) return macros +def noise_bend(features: Features) -> Envelope[int]: + """The period steps a slice's bend moves the noise channel by on each tick. + + Bitphase adds a tone offset to the noise channel's note, and the note index carries the + period itself (see :func:`noise_period_to_note_index`), so each step of the bend is one step + of the period, as the slice reads it. + + Args: + features: The per-dimension envelopes describing the slice. + + Returns: + Envelope[int]: The steps per tick, empty where the slice bends nothing. + """ + bend = stored_envelope(FeatureKey.PITCH, bend_envelope(features.pitch, features.hi_pitch)) + if not any(bend.items): + return Envelope[int]() + + return bend + + def _bend_envelope( features: Features, contour: Envelope[int], diff --git a/src/sampletones_core/formats/bitphase/preset.py b/src/sampletones_core/formats/bitphase/preset.py index 7046b71eb..33d140604 100644 --- a/src/sampletones_core/formats/bitphase/preset.py +++ b/src/sampletones_core/formats/bitphase/preset.py @@ -7,7 +7,7 @@ from sampletones_core.exporters.feature import Features from sampletones_core.exports.request import InstrumentExport from sampletones_core.features.envelope import Envelope -from sampletones_core.formats.bitphase.envelopes import ChannelEnvelopes, features_to_envelopes +from sampletones_core.formats.bitphase.envelopes import ChannelEnvelopes, features_to_envelopes, noise_bend 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 @@ -31,11 +31,12 @@ def _tone_offsets( 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. A preset loads into a document that keeps its own tuning, so the offsets stay at that tuning whatever the slice was - tuned at. The noise channel takes its period from the note, so its offsets stay flat and the - note carries the pitch. + tuned at. On the noise channel an offset moves the note, which carries the period itself, so + each tick's offset is its contour step and its bend, in period steps. """ if channel == ChannelName.NOISE: - return (NO_TONE_OFFSET,) * len(contour) + bend = noise_bend(features) + return tuple(semitones + _value(bend, tick) for tick, semitones in enumerate(contour)) base_index = pitch_to_note_index(features.initial_pitch) base_period = DEFAULT_TUNING_TABLE[base_index] diff --git a/src/sampletones_core/formats/bitphase/specification/effects.py b/src/sampletones_core/formats/bitphase/specification/effects.py index 7be9facca..57321cade 100644 --- a/src/sampletones_core/formats/bitphase/specification/effects.py +++ b/src/sampletones_core/formats/bitphase/specification/effects.py @@ -18,7 +18,6 @@ class EffectId(IntEnum): SPEED_EFFECT_DELAY: Final[int] = 0 ORNAMENT_POSITION_DELAY: Final[int] = 0 MAX_ORNAMENT_POSITION: Final[int] = 0xFF -NO_EFFECT_PARAMETER: Final[int] = 0 NO_EFFECT_TABLE: Final[int] = -1 MIN_EFFECT_COLUMNS: Final[int] = 1 diff --git a/src/sampletones_core/formats/bitphase/transposes.py b/src/sampletones_core/formats/bitphase/transposes.py index 94ff4c6de..7bc339a1e 100644 --- a/src/sampletones_core/formats/bitphase/transposes.py +++ b/src/sampletones_core/formats/bitphase/transposes.py @@ -17,7 +17,7 @@ from sampletones_core.formats.bitphase.specification.patterns import TABLE_COLUMN_OFFSET from sampletones_core.formats.bitphase.voices import SliceVoice, SliceVoiceTable from sampletones_core.project.song import Song -from sampletones_core.timing import Groove +from sampletones_core.timing import SongTiming NOTE_SHIFT: Final[int] = 0 MOVED_TABLE_NAME: Final[str] = "{name} {shift:+d}" @@ -128,14 +128,14 @@ def build( cls, song: Song, voices: SliceVoiceTable, - groove: Groove, + timing: SongTiming, *, first_table_id: int, ) -> TransposePlan: """Plans every transpose row of the song, following the notes the order sounds. A note's table advances a step every tick, so the step a transpose row reaches is read from - the ticks the groove gives every row since the note, across frames. An ornament position + the ticks the song's timing gives every row since the note, across frames. An ornament position names a step up to ``MAX_ORNAMENT_POSITION``, so a row reaching a later step names a copy of the table opening on that step. A row keeping the shift the channel already carries writes nothing. @@ -143,7 +143,7 @@ def build( Args: song: The arrangement being exported. voices: The slices a row's note-on reaches, by voice and channel. - groove: The ticks each row of a pattern lasts in the document. + timing: How many ticks every row of the song lasts in the document. first_table_id: The id the first moved table takes, above every other table. Returns: @@ -161,7 +161,8 @@ def build( cls._note_cells( note, voices[(note.voice_id, channel_name)], - groove, + timing, + song.order_length(), shelf, ) ) @@ -182,7 +183,8 @@ def frame_cells(self, channel_name: ChannelName, order_position: int) -> Dict[in def _note_cells( note: SoundingNote, voice: SliceVoice, - groove: Groove, + timing: SongTiming, + frames: int, shelf: _TableShelf, ) -> Dict[Tuple[int, int], TransposeCell]: """The cells the transpose rows reaching one note write.""" @@ -195,7 +197,14 @@ def _note_cells( continue carried = shift - position = voice.table.position_at(groove.ticks_across(note.place.row_index, repitch.rows)) + position = voice.table.position_at( + timing.ticks_across( + note.place.order_position, + note.place.row_index, + repitch.rows, + frames=frames, + ) + ) cells[(repitch.place.order_position, repitch.place.row_index)] = TransposePlan._attached( voice, shift, diff --git a/src/sampletones_core/formats/famitracker/sequences/features.py b/src/sampletones_core/formats/famitracker/sequences/features.py index f1bd56d84..db43eec8e 100644 --- a/src/sampletones_core/formats/famitracker/sequences/features.py +++ b/src/sampletones_core/formats/famitracker/sequences/features.py @@ -19,6 +19,7 @@ ) NO_ARPEGGIO_STEP: Final[int] = 0 +NO_BEND_STEP: Final[int] = 0 FLAT_RUNNING_ARPEGGIO: Final[Envelope[int]] = Envelope[int](items=(NO_ARPEGGIO_STEP,), loop_point=LOOP_FROM_START) @@ -36,8 +37,8 @@ def features_to_instrument_sequences( holds — see :func:`stored_envelope`. A bend travels with an arpeggio that runs beside it, which is what makes the bend an offset - from the note — see :func:`_pinning_arpeggio`. An instrument a note slide reaches keeps its - arpeggio and its bend running for as long as the note sounds — see :func:`_running`. + from the note — see :func:`_pinned`. An instrument a note slide reaches keeps its arpeggio and + its bend running for as long as the note sounds — see :func:`_running`. Args: features: The per-dimension envelopes describing the slice. @@ -102,7 +103,7 @@ def features_truncation(features: Features) -> Optional[EnvelopeTruncation]: def _pinned(stored: Dict[SequenceKind, Envelope[int]]) -> Dict[SequenceKind, Envelope[int]]: - """These sequences with an arpeggio that runs for as long as the bend beside it does. + """These sequences with an arpeggio that runs for as long as the bend beside it acts. FamiTracker walks an instrument's sequences in slot order, and an arpeggio in absolute mode reloads the period from the note before the bend sequences add to it. So while the arpeggio @@ -110,21 +111,34 @@ def _pinned(stored: Dict[SequenceKind, Envelope[int]]) -> Dict[SequenceKind, Env arpeggio halts, the same items start accumulating on the running period instead. Writing an arpeggio that covers the bend is what holds the two readings together. + A bend that repeats, or ends on an offset it then holds, acts for as long as the note sounds, + so the arpeggio and the bend both run on (see :func:`_running`). A bend ending with no offset + acts until its last item, which an arpeggio reaching its length covers. + Args: stored: The sequences as the file holds them. Returns: - Dict[SequenceKind, Envelope[int]]: Those sequences, the arpeggio reaching the bend's length. + Dict[SequenceKind, Envelope[int]]: Those sequences, the arpeggio covering the bend. """ - bend_length = max((len(stored.get(kind, Envelope[int]()).items) for kind in BEND_SEQUENCE_KINDS), default=0) - if not bend_length: + bends = [bend for kind in BEND_SEQUENCE_KINDS if (bend := stored.get(kind, Envelope[int]())).items] + if not bends: return stored + if any(_acts_throughout(bend) for bend in bends): + return _running(stored) + + bend_length = max(len(bend.items) for bend in bends) return {**stored, SequenceKind.ARPEGGIO: _pinning_arpeggio(stored, bend_length)} +def _acts_throughout(bend: Envelope[int]) -> bool: + """Whether a bend offsets the note for as long as it sounds: it repeats, or it holds an offset when it ends.""" + return bend.loops or bend.items[-1] != NO_BEND_STEP + + def _pinning_arpeggio(stored: Dict[SequenceKind, Envelope[int]], bend_length: int) -> Envelope[int]: - """The arpeggio that reloads the note for every tick a bend states an offset for. + """The arpeggio that reloads the note for every tick a bend ending with no offset states one for. An arpeggio circling from a point runs for as long as the note sounds and needs nothing; one playing its items once holds its final note over the remaining ticks, which is the note it diff --git a/src/sampletones_core/formats/famitracker/specification/patterns.py b/src/sampletones_core/formats/famitracker/specification/patterns.py index 60ec08a83..bcbd4c1a3 100644 --- a/src/sampletones_core/formats/famitracker/specification/patterns.py +++ b/src/sampletones_core/formats/famitracker/specification/patterns.py @@ -24,14 +24,17 @@ class EffectId(IntEnum): ``SLIDE_UP`` is ``Qxy`` and ``SLIDE_DOWN`` is ``Rxy``: each moves the channel's note by ``y`` semitones at once and glides the period toward it at ``2x + 1`` units a tick. A module below the - 0CC version stores these numbers verbatim (``EF_SLIDE_UP``, ``EF_SLIDE_DOWN``). + 0CC version stores these numbers verbatim (``EF_SLIDE_UP``, ``EF_SLIDE_DOWN``). ``DAC`` is + ``Zxx`` (``EF_DAC``): it loads ``xx``, at most ``MAX_DAC_LEVEL``, into the DMC's output level. """ + DAC = 15 SLIDE_UP = 20 SLIDE_DOWN = 21 MAX_SLIDE_SEMITONES: Final[int] = 0x0F +MAX_DAC_LEVEL: Final[int] = 0x7F FASTEST_SLIDE_SPEED: Final[int] = 0x0F SLIDE_SPEED_SHIFT: Final[int] = 4 diff --git a/src/sampletones_core/performance/song.py b/src/sampletones_core/performance/song.py index 0c050c620..7e7d77c3d 100644 --- a/src/sampletones_core/performance/song.py +++ b/src/sampletones_core/performance/song.py @@ -14,6 +14,7 @@ from sampletones_core.project.project import Project from sampletones_core.project.song_position import SongPosition from sampletones_core.project.voices.voice import VoiceUnion +from sampletones_core.timing.bounds import SONG_TICK_BOUNDS from sampletones_core.timing.song import SongTiming from sampletones_shared.utils.progress import silent_reporter @@ -25,11 +26,11 @@ def song_instructions( """Plays a whole song out as the instructions each channel sounds, one per engine tick. The order is walked frame by frame and row by row, each row lasting the ticks the project's - groove gives its position within the pattern. Every channel answers for each of those ticks, - so the four streams share one length and a tick's index into them is the same moment of the - song — which is what an engine consuming one instruction per tick plays from. + timing gives its place in the song. Every channel answers for each of those ticks, so the four + streams share one length and a tick's index into them is the same moment of the song — which is + what an engine consuming one instruction per tick plays from. - The groove states the ticks the whole order lasts before a row is played, so a walk of a song + The timing states the ticks the whole order lasts before a row is played, so a walk of a song of minutes says how far along it is and answers a caller who no longer wants it. Args: @@ -43,8 +44,7 @@ def song_instructions( OperationCanceled: If ``report`` withdraws the walk. """ song = project.song - timing = SongTiming.from_project(project) - groove = timing.groove() + timing = SongTiming.from_project(project, bounds=SONG_TICK_BOUNDS) total = timing.frame_tick(song.order_length()) performances = {channel_name: ChannelPerformance() for channel_name in ChannelName.items()} streams: Dict[ChannelName, List[InstructionUnion]] = {channel_name: [] for channel_name in ChannelName.items()} @@ -52,7 +52,7 @@ def song_instructions( position = SongPosition() walked = 0 while position.order_position < song.order_length(): - ticks = groove.ticks[position.row_index] + ticks = timing.row_ticks(position.order_position, position.row_index) for channel_name in ChannelName.items(): performance = performances[channel_name] row = resolve_row(song, position, channel_name) diff --git a/src/sampletones_core/project/voices/envelopes.py b/src/sampletones_core/project/voices/envelopes.py index a20725ff0..95f85c175 100644 --- a/src/sampletones_core/project/voices/envelopes.py +++ b/src/sampletones_core/project/voices/envelopes.py @@ -28,7 +28,8 @@ class InstrumentEnvelopes(BaseModel): Each dimension carries the widest range the four channels offer, and a channel takes what it reads: an arpeggio item is a semitone offset on the tonal channels and a period offset on - noise, and a duty-cycle item selects a pulse waveform or the noise channel's short mode. An + noise, a pitch item bends a divider on the tonal channels and moves the noise period a step, + and a duty-cycle item selects a pulse waveform, its lowest bit the noise channel's short mode. An empty envelope leaves that dimension to the channel, which sounds it at the value every note starts on — the same record a reconstruction's held dimensions carry. diff --git a/src/sampletones_core/project/voices/instrument.py b/src/sampletones_core/project/voices/instrument.py index 19f9dabf1..bc9c44801 100644 --- a/src/sampletones_core/project/voices/instrument.py +++ b/src/sampletones_core/project/voices/instrument.py @@ -17,10 +17,11 @@ RESTING_REFERENCE_PERIOD, RESTING_REFERENCE_PITCH, channel_reference, + speaks_in_periods, supported_features, - supports, ) from sampletones_core.features.envelope import Envelope +from sampletones_core.features.spec import reads_from_instrument from sampletones_core.instructions import InstructionUnion from sampletones_core.project.voices.envelopes import InstrumentEnvelopes @@ -80,15 +81,31 @@ def held_features(self, channel_name: ChannelName) -> Tuple[FeatureKey, ...]: """The dimensions this channel governs: those it offers and the instrument leaves empty.""" kind = CHANNEL_GENERATOR_KIND[channel_name] return tuple( - feature_key for feature_key in supported_features(kind) if not self.envelopes.envelope(feature_key).written + feature_key for feature_key in supported_features(kind) if not self._writes(channel_name, feature_key) + ) + + def _writes(self, channel_name: ChannelName, feature_key: FeatureKey) -> bool: + """Whether the instrument decides a dimension on a channel. + + The noise channel plays a bend as steps of the period the arpeggio sets, so a frame carries + the two as one period, and an instrument writing a bend there decides the arpeggio too. + """ + if self.envelopes.envelope(feature_key).written: + return True + + return ( + feature_key is FeatureKey.ARPEGGIO + and speaks_in_periods(channel_name) + and self.envelopes.envelope(FeatureKey.PITCH).written ) def features(self, channel_name: ChannelName) -> Features: """The envelopes as this channel reads them, measured against the instrument's pitch. - A channel takes the dimensions its generator offers and leaves the rest absent, which is - what makes one set of envelopes serve every channel. Each dimension travels with the item - it repeats from, so a channel reads a loop the way the instrument wrote it. + A channel takes the dimensions it reads of an instrument (see :func:`reads_from_instrument`) + and leaves the rest absent, which is what makes one set of envelopes serve every channel. + Each dimension travels with the item it repeats from, so a channel reads a loop the way the + instrument wrote it. Args: channel_name: The channel reading the instrument. @@ -131,12 +148,12 @@ def instruction_at(self, channel_name: ChannelName, tick: int) -> InstructionUni return CHANNEL_TO_EXPORTER_MAP[channel_name].from_features(features)[0] def _offered(self, channel_name: ChannelName) -> Dict[FeatureKey, Envelope[int]]: - """The dimensions this channel's generator reads, as the instrument writes them.""" + """The dimensions this channel reads of the instrument, as the instrument writes them.""" kind = CHANNEL_GENERATOR_KIND[channel_name] return { feature_key: envelope for feature_key, envelope in self.envelopes.envelope_map.items() - if supports(kind, feature_key) + if reads_from_instrument(kind, feature_key) } @cached_property diff --git a/src/sampletones_core/timing/__init__.py b/src/sampletones_core/timing/__init__.py index bd4a5a5b0..273e75d85 100644 --- a/src/sampletones_core/timing/__init__.py +++ b/src/sampletones_core/timing/__init__.py @@ -1,20 +1,24 @@ -from .bounds import MAX_TICKS_PER_ROW, MIN_TICKS_PER_ROW +from .bounds import MAX_TICKS_PER_ROW, MIN_TICKS_PER_ROW, SONG_TICK_BOUNDS, TickBounds from .clock import TickClock -from .distribution import distribute_by_halving, distribute_proportionally -from .groove import Groove, calculate_groove +from .distribution import nearest, split_by_halving +from .groove import Groove, bar_line, plan_bar from .meter import Meter from .rate import RowRate -from .song import SongTiming +from .song import BarSlot, SongTiming __all__ = [ "MAX_TICKS_PER_ROW", "MIN_TICKS_PER_ROW", + "SONG_TICK_BOUNDS", + "BarSlot", "Groove", "Meter", "RowRate", "SongTiming", + "TickBounds", "TickClock", - "calculate_groove", - "distribute_by_halving", - "distribute_proportionally", + "bar_line", + "nearest", + "plan_bar", + "split_by_halving", ] diff --git a/src/sampletones_core/timing/bounds.py b/src/sampletones_core/timing/bounds.py index 8c3da006f..2454b4dcc 100644 --- a/src/sampletones_core/timing/bounds.py +++ b/src/sampletones_core/timing/bounds.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass from math import ceil from typing import Final @@ -13,3 +14,25 @@ nes_frequency=MAX_NES_FREQUENCY, ).ticks_per_row ) + + +@dataclass(frozen=True) +class TickBounds: + """The shortest and the longest an engine holds a row for, in ticks. + + In-app playback and the NSF reach every row the settings ask for, while a tracker's speed column + states a narrower range, so each player names the range its rows are held within. + + Attributes: + minimum: The fewest ticks a row lasts. + maximum: The most ticks a row lasts. + """ + + minimum: int + maximum: int + + +SONG_TICK_BOUNDS: Final[TickBounds] = TickBounds( + minimum=MIN_TICKS_PER_ROW, + maximum=MAX_TICKS_PER_ROW, +) diff --git a/src/sampletones_core/timing/distribution.py b/src/sampletones_core/timing/distribution.py index 53d511ab7..a51c5e4dc 100644 --- a/src/sampletones_core/timing/distribution.py +++ b/src/sampletones_core/timing/distribution.py @@ -1,77 +1,74 @@ -from typing import List, Sequence, Tuple +from fractions import Fraction +from math import ceil, floor +from typing import Final, Tuple +HALF: Final[Fraction] = Fraction(1, 2) +SINGLE_ROW: Final[int] = 1 -def _divide_rounding_up(dividend: int, divisor: int) -> int: - """Divides two integers, carrying a fractional result up to the next integer.""" - return -(-dividend // divisor) +def nearest(value: Fraction) -> int: + """The whole number closest to ``value``, a half rounding up.""" + return floor(value + HALF) -def distribute_proportionally( + +def split_by_halving( total: int, - lengths: Sequence[int], + spans: Tuple[int, ...], + *, + row_ticks: Fraction, ) -> Tuple[int, ...]: - """Shares a tick total among consecutive spans in proportion to their row counts. + """Shares a tick total among the rows of consecutive spans by halving them down to single rows. - Each span ends at a boundary rounded up from its exact share, so where a share falls - between two integers the surplus tick goes to the earlier span. Over a pattern this - puts the longer rows on the earlier, metrically stronger positions. + The spans are cut in two, the larger part first, so three spans part as two and one. Each part + takes the share of the total nearest its exact length, and each part is then halved again. A + single span is cut between its own rows. The surplus ticks therefore settle where a listener hears + the strongest positions: the first half of a bar's beats, then the halves of those, and inside a + beat its first row, then its middle, then its quarters. - Only the floor and the ceiling of the average per row ever appear, which is what lets - a caller hold every row within an engine's speed range by bounding ``total`` alone. + Every row of the spans lasts ``row_ticks`` exactly, and each part's share is held to what its rows + can carry, so every row lasts the floor or the ceiling of ``row_ticks``. Args: - total: The tick count the spans share. - lengths: The row count of each span, in order, each at least 1. + total: The ticks the spans share, between the floor and the ceiling of their exact length. + spans: The row count of each consecutive span, in order, each at least 1. + row_ticks: The exact ticks one row lasts. Returns: - Tuple[int, ...]: One tick total per span, together summing to ``total``. + Tuple[int, ...]: One tick count per row of the spans, together summing to ``total``. Raises: - ValueError: If no span is given, or a span holds fewer than one row. + ValueError: If no span is given, a span holds no row, or ``total`` lies outside what the rows + carry at the floor and the ceiling of ``row_ticks``. """ - if not lengths: + if not spans: raise ValueError("At least one span is required to share a tick total") - if any(length < 1 for length in lengths): - raise ValueError(f"Every span must hold at least 1 row, got {tuple(lengths)}") - - rows = sum(lengths) - shares: List[int] = [] - cumulative = 0 - boundary = 0 - for length in lengths: - cumulative += length - previous, boundary = boundary, _divide_rounding_up(total * cumulative, rows) - shares.append(boundary - previous) - - return tuple(shares) - - -def distribute_by_halving(total: int, rows: int) -> Tuple[int, ...]: - """Shares a tick total among rows by halving the span down to single rows. - - The earlier half takes the extra row where the count is odd and the surplus tick - where the share is fractional, so within a beat the longer rows fall on the positions - a listener hears as strong: the first row, then the halfway row, then the quarters. - - Args: - total: The tick count the rows share. - rows: How many rows share it, at least 1. + if any(span < SINGLE_ROW for span in spans): + raise ValueError(f"Every span must hold at least 1 row, got {spans}") - Returns: - Tuple[int, ...]: One tick count per row, together summing to ``total``. + rows = sum(spans) + if not rows * floor(row_ticks) <= total <= rows * ceil(row_ticks): + raise ValueError(f"{rows} rows of {row_ticks} ticks each carry no total of {total}") - Raises: - ValueError: If fewer than one row is given. - """ - if rows < 1: - raise ValueError(f"rows must be at least 1, got {rows}") + return _halved(total, spans, row_ticks) - if rows == 1: - return (total,) - left = _divide_rounding_up(rows, 2) - right = rows - left - halves = distribute_proportionally(total, (left, right)) - - return distribute_by_halving(halves[0], left) + distribute_by_halving(halves[1], right) +def _halved( + total: int, + spans: Tuple[int, ...], + row_ticks: Fraction, +) -> Tuple[int, ...]: + if len(spans) == 1: + if spans[0] == SINGLE_ROW: + return (total,) + + spans = (SINGLE_ROW,) * spans[0] + + middle = ceil(len(spans) / 2) + first, second = spans[:middle], spans[middle:] + first_rows, second_rows = sum(first), sum(second) + share = nearest(total * Fraction(first_rows, first_rows + second_rows)) + lowest = max(first_rows * floor(row_ticks), total - second_rows * ceil(row_ticks)) + highest = min(first_rows * ceil(row_ticks), total - second_rows * floor(row_ticks)) + share = min(max(share, lowest), highest) + return _halved(share, first, row_ticks) + _halved(total - share, second, row_ticks) diff --git a/src/sampletones_core/timing/groove.py b/src/sampletones_core/timing/groove.py index 459819f53..0cb9963c6 100644 --- a/src/sampletones_core/timing/groove.py +++ b/src/sampletones_core/timing/groove.py @@ -1,26 +1,21 @@ from dataclasses import dataclass from fractions import Fraction -from math import floor -from typing import Final, List, Tuple +from functools import lru_cache +from typing import Final, Tuple -from sampletones_core.timing.distribution import ( - distribute_by_halving, - distribute_proportionally, -) -from sampletones_core.timing.meter import Meter -from sampletones_core.timing.rate import RowRate +from sampletones_core.timing.distribution import nearest, split_by_halving -HALF: Final[Fraction] = Fraction(1, 2) +BAR_PLAN_CACHE_SIZE: Final[int] = 4096 @dataclass(frozen=True) class Groove: - """The engine ticks each row of a pattern lasts. + """The engine ticks each row of one pattern lasts, where an order frame plays it. An engine that takes one speed value per row reaches a fractional row rate by varying that value from row to row, which is how a tempo its speed column alone cannot state - still comes out right on average. The variation is placed by meter, so the longer rows - land on the bar, then the beat, then the subdivisions inside a beat. + still comes out right on average. Every bar starts on the tick nearest its exact start, + and inside a bar the meter places the longer rows on the strongest positions first. Attributes: ticks: One tick count per pattern row, in order. @@ -28,116 +23,37 @@ class Groove: ticks: Tuple[int, ...] - @property - def total_ticks(self) -> int: - """How many engine ticks the whole pattern lasts.""" - return sum(self.ticks) - - @property - def mean_ticks_per_row(self) -> Fraction: - """The row rate the groove realizes, which states what a bounded groove reached.""" - return Fraction(self.total_ticks, len(self.ticks)) - - @property - def is_uniform(self) -> bool: - """Whether every row lasts alike, so a single speed value carries the tempo.""" - return len(set(self.ticks)) == 1 - - def ticks_across(self, row_index: int, rows: int) -> int: - """How many engine ticks pass over ``rows`` rows starting at ``row_index``. - - Every frame of an order plays one whole pattern, so a span running past the pattern's last - row goes on from the first row of the next one. - - Args: - row_index: The row the span starts on, within the pattern. - rows: How many rows the span covers, at least zero. - - Returns: - int: The ticks those rows last. - """ - length = len(self.ticks) - patterns, remainder = divmod(rows, length) - opening = sum(self.ticks[(row_index + offset) % length] for offset in range(remainder)) - return patterns * self.total_ticks + opening - - -def _pattern_ticks( - rate: RowRate, - rows: int, - *, - minimum_ticks: int, - maximum_ticks: int, -) -> int: - """Rounds a pattern's exact tick count to the nearest integer within the engine's speed range. - - Rounding once, on the pattern, is what makes the pattern's duration the closest the - engine reaches; the meter then decides which rows carry the difference. Bounding the - pattern total rather than each row keeps every row inside the range as a consequence, - since a proportional split yields only the floor and the ceiling of the average. + +def bar_line(row: int, row_ticks: Fraction) -> int: + """The tick a bar starting on ``row`` of the song starts on: the one nearest its exact start. Args: - rate: The exact ticks one row lasts. - rows: The pattern's row count. - minimum_ticks: The fewest ticks the engine holds a row for. - maximum_ticks: The most ticks the engine holds a row for. + row: The row the bar starts on, counted from the song's first row. + row_ticks: The exact ticks one row lasts. Returns: - int: The tick count the pattern's rows share. + int: The tick, counted from the song's first. """ - exact = rate.ticks_per_row * rows - return min( - max(floor(exact + HALF), rows * minimum_ticks), - rows * maximum_ticks, - ) - - -def calculate_groove( - rate: RowRate, - meter: Meter, - *, - minimum_ticks: int, - maximum_ticks: int, -) -> Groove: - """Builds the per-row tick counts that carry a row rate across one pattern. - - The pattern's tick total is shared among its bars, each bar's among its beats, and - each beat's among its rows by halving — one rule applied at three levels, so the - surplus ticks settle on the strongest position each level offers. + return nearest(row * row_ticks) + + +@lru_cache(maxsize=BAR_PLAN_CACHE_SIZE) +def plan_bar( + total: int, + beats: Tuple[int, ...], + row_ticks: Fraction, +) -> Tuple[int, ...]: + """The ticks each row of a bar lasts, the bar's total halved down its beats and rows. + + A bar lasts one of two totals wherever it falls in a song, the floor or the ceiling of its exact + length, so each bar of a pattern has at most two plans, and they are kept once computed. Args: - rate: The exact ticks one row lasts. - meter: The pattern's length and its beat and bar grouping. - minimum_ticks: The fewest ticks the engine holds a row for. - maximum_ticks: The most ticks the engine holds a row for. + total: The ticks the bar lasts. + beats: The row count of each of the bar's beats. + row_ticks: The exact ticks one row lasts. Returns: - Groove: One tick count per row of the pattern. + Tuple[int, ...]: One tick count per row of the bar. """ - total = _pattern_ticks( - rate, - meter.rows, - minimum_ticks=minimum_ticks, - maximum_ticks=maximum_ticks, - ) - bars = meter.spans - bar_lengths = tuple(sum(beats) for beats in bars) - - ticks: List[int] = [] - for beats, bar_ticks in zip( - bars, - distribute_proportionally( - total, - bar_lengths, - ), - ): - for beat_rows, beat_ticks in zip( - beats, - distribute_proportionally( - bar_ticks, - beats, - ), - ): - ticks.extend(distribute_by_halving(beat_ticks, beat_rows)) - - return Groove(ticks=tuple(ticks)) + return split_by_halving(total, beats, row_ticks=row_ticks) diff --git a/src/sampletones_core/timing/rate.py b/src/sampletones_core/timing/rate.py index a3c032418..0f06c60e9 100644 --- a/src/sampletones_core/timing/rate.py +++ b/src/sampletones_core/timing/rate.py @@ -75,3 +75,29 @@ def from_settings(cls, settings: ProjectSettings) -> RowRate: speed=settings.speed, nes_frequency=settings.nes_frequency, ) + + def bounded( + self, + *, + minimum_ticks: int, + maximum_ticks: int, + ) -> RowRate: + """The rate an engine plays this one at, every row lasting between its shortest and longest. + + A tempo asking for rows shorter than the engine's shortest plays every row at that shortest + length, so the song runs slower than its tempo states. FamiTracker and Bitphase play such a + tempo the same way. + + Args: + minimum_ticks: The fewest ticks the engine holds a row for. + maximum_ticks: The most ticks the engine holds a row for. + + Returns: + RowRate: The rate, held between the two. + """ + return RowRate( + ticks_per_row=min( + max(self.ticks_per_row, Fraction(minimum_ticks)), + Fraction(maximum_ticks), + ), + ) diff --git a/src/sampletones_core/timing/song.py b/src/sampletones_core/timing/song.py index b21cfc933..ffc3250c6 100644 --- a/src/sampletones_core/timing/song.py +++ b/src/sampletones_core/timing/song.py @@ -1,56 +1,147 @@ from dataclasses import dataclass -from typing import Self +from fractions import Fraction +from functools import cached_property +from typing import Dict, Self, Tuple from sampletones_core.project import Project -from sampletones_core.timing.bounds import MAX_TICKS_PER_ROW, MIN_TICKS_PER_ROW -from sampletones_core.timing.groove import Groove, calculate_groove +from sampletones_core.timing.bounds import TickBounds +from sampletones_core.timing.groove import Groove, bar_line, plan_bar from sampletones_core.timing.meter import Meter from sampletones_core.timing.rate import RowRate +@dataclass(frozen=True) +class BarSlot: + """One bar of a pattern: the row it starts on and the row count of each of its beats. + + Attributes: + first_row: The pattern row the bar starts on. + beats: The row count of each of the bar's beats, in order. + """ + + first_row: int + beats: Tuple[int, ...] + + @property + def rows(self) -> int: + """How many rows the bar spans.""" + return sum(self.beats) + + @dataclass(frozen=True) class SongTiming: - """Everything a project's groove is built from, held together so a change is one comparison. + """How many engine ticks every row of a song lasts, wherever the order plays it. + + Every bar starts on the tick nearest its exact start, counted from the song's first row, so + the rounding never adds up and the song keeps its tempo however long it plays. Inside a bar, + the bar's ticks are halved down its beats and rows, so the surplus settles on the strongest + positions. The meter restarts at every pattern, as a tracker's highlights do, so every order + frame starts a bar. Where the bar lines fall depends on how far into the song a frame plays, + so two frames can differ by a tick in a bar. + + Each answer follows from the row's place alone. A player reaching a row, a seek and an export + asking where a frame starts all read it directly. Once the exact lengths of a run of patterns add + up to whole ticks, every frame after it plays the groove of the frame that many before, so each + groove is planned once and kept. Attributes: rate: The exact ticks one row lasts under the project's tempo, speed and tick rate. meter: The pattern length and the beat and bar grouping the ticks are spread over. + bounds: The shortest and the longest the player holds a row for. """ rate: RowRate meter: Meter + bounds: TickBounds @classmethod - def from_project(cls, project: Project) -> Self: - """Reads the timing a project plays at, taking the pattern length from its song.""" + def from_project(cls, project: Project, *, bounds: TickBounds) -> Self: + """Reads the timing a project plays at, taking the pattern length from its song. + + Args: + project: The project whose settings and song set the timing. + bounds: The shortest and the longest the player holds a row for. + + Returns: + Self: The project's timing. + """ return cls( rate=RowRate.from_settings(project.settings), meter=Meter.from_settings( project.settings, rows=project.song.rows_per_pattern, ), + bounds=bounds, ) - def groove(self) -> Groove: - """Spreads the row rate across a pattern's rows. + @cached_property + def exact_row_ticks(self) -> Fraction: + """The exact ticks one row lasts, held within what the player plays. - A song runs at whatever tempo the project states, so the one bound that applies is that - every row lasts at least a tick and keeps sounding; the ceiling is the slowest row the - settings can ask for, which leaves the groove free to realize the rate exactly. + A tempo asking for rows shorter than the shortest the player holds plays every row at that + shortest length, so the song runs slower than its tempo states. """ - return calculate_groove( - self.rate, - self.meter, - minimum_ticks=MIN_TICKS_PER_ROW, - maximum_ticks=MAX_TICKS_PER_ROW, - ) + return self.rate.bounded( + minimum_ticks=self.bounds.minimum, + maximum_ticks=self.bounds.maximum, + ).ticks_per_row + + @cached_property + def bars(self) -> Tuple[BarSlot, ...]: + """Every bar of a pattern, in order.""" + slots = [] + first_row = 0 + for beats in self.meter.spans: + slots.append(BarSlot(first_row=first_row, beats=beats)) + first_row += sum(beats) + + return tuple(slots) + + @cached_property + def period(self) -> int: + """How many frames pass before the grooves repeat: the patterns whose exact lengths add up to whole ticks.""" + return (self.exact_row_ticks * self.meter.rows).denominator + + @cached_property + def planned(self) -> Dict[int, Groove]: + """The grooves planned so far, by their frame's place within the period.""" + return {} + + def groove(self, frame: int) -> Groove: + """The ticks each row of the pattern lasts where order frame ``frame`` plays it. + + Args: + frame: The order position, counted from 0. + + Returns: + Groove: One tick count per pattern row. + """ + phase = frame % self.period + groove = self.planned.get(phase) + if groove is None: + groove = Groove(ticks=tuple(ticks for bar in self.bars for ticks in self._bar_plan(phase, bar))) + self.planned[phase] = groove + + return groove + + def row_ticks(self, frame: int, row: int) -> int: + """The ticks one row lasts where order frame ``frame`` plays it. + + Args: + frame: The order position, counted from 0. + row: The row within the pattern. + + Returns: + int: The ticks the row lasts. + """ + return self.groove(frame).ticks[row] def frame_tick(self, frame: int) -> int: """The engine tick order frame ``frame`` starts on. - Every pattern holds the song's row count, so each frame the order plays lasts one groove - and a frame starts once that many grooves have played. The frame one past the order's last - is where the song ends, so its tick is the length of the whole song. + A frame starts a bar, so it starts on the tick nearest its exact start. The frame one past + the order's last is where the song ends, so its tick is the length of the whole song: the + whole number of ticks nearest its exact length. Args: frame: The order position, counted from 0. @@ -58,4 +149,49 @@ def frame_tick(self, frame: int) -> int: Returns: int: The ticks the order plays before ``frame`` begins. """ - return self.groove().total_ticks * frame + return bar_line(frame * self.meter.rows, self.exact_row_ticks) + + def tick_at(self, frame: int, row: int) -> int: + """The engine tick a row starts on, counted from the song's first tick. + + Args: + frame: The order position, counted from 0. + row: The row within the pattern. + + Returns: + int: The ticks the song plays before the row begins. + """ + return self.frame_tick(frame) + sum(self.groove(frame).ticks[:row]) + + def ticks_across( + self, + frame: int, + row: int, + rows: int, + *, + frames: int, + ) -> int: + """How many engine ticks pass over ``rows`` rows starting at a row of the song. + + The order plays its frames in turn and returns to the first after the last, and every pass + through it plays alike, so a span running past the song's end goes on from its start. + + Args: + frame: The order position the span starts in. + row: The row within that frame's pattern the span starts on. + rows: How many rows the span covers, at least zero. + frames: How many frames the order plays before it returns to the first. + + Returns: + int: The ticks those rows last. + """ + pattern_rows = self.meter.rows + passes, remainder = divmod(frame * pattern_rows + row + rows, frames * pattern_rows) + end_frame, end_row = divmod(remainder, pattern_rows) + end = passes * self.frame_tick(frames) + self.tick_at(end_frame, end_row) + return end - self.tick_at(frame, row) + + def _bar_plan(self, frame: int, bar: BarSlot) -> Tuple[int, ...]: + start = frame * self.meter.rows + bar.first_row + total = bar_line(start + bar.rows, self.exact_row_ticks) - bar_line(start, self.exact_row_ticks) + return plan_bar(total, bar.beats, self.exact_row_ticks) diff --git a/src/sampletones_player/specification/nsf.py b/src/sampletones_player/specification/nsf.py index 5a3751a61..4b431c74f 100644 --- a/src/sampletones_player/specification/nsf.py +++ b/src/sampletones_player/specification/nsf.py @@ -42,6 +42,8 @@ NTSC_PLAY_PERIOD_MICROSECONDS: Final[int] = round(MICROSECONDS_PER_SECOND / NTSC_FRAME_RATE) PAL_PLAY_PERIOD_MICROSECONDS: Final[int] = 20000 NTSC_REGION: Final[int] = 0x00 +PAL_REGION_FLAG: Final[int] = 0x01 +DUAL_REGION_FLAG: Final[int] = 0x02 NO_EXPANSION_CHIPS: Final[int] = 0x00 NO_NSF2_FEATURES: Final[int] = 0x00 NO_BANKSWITCHING: Final[bytes] = bytes(BANKSWITCH_SIZE) diff --git a/src/sampletones_player/specification/registers.py b/src/sampletones_player/specification/registers.py index e44e09c75..01b93ec7f 100644 --- a/src/sampletones_player/specification/registers.py +++ b/src/sampletones_player/specification/registers.py @@ -16,6 +16,7 @@ NOISE_CONTROL: Final[int] = 0x400C NOISE_PERIOD: Final[int] = 0x400E NOISE_LENGTH_COUNTER: Final[int] = 0x400F +DMC_DIRECT_LOAD: Final[int] = 0x4011 APU_STATUS: Final[int] = 0x4015 APU_FRAME_COUNTER: Final[int] = 0x4017 diff --git a/src/sampletones_shared/paths/extensions.py b/src/sampletones_shared/paths/extensions.py index 7e8f5642b..d696b22f1 100644 --- a/src/sampletones_shared/paths/extensions.py +++ b/src/sampletones_shared/paths/extensions.py @@ -10,6 +10,8 @@ EXT_FILE_MODULE: Final[str] = ".ftm" EXT_FILE_BITPHASE: Final[str] = ".btp" EXT_FILE_NSF: Final[str] = ".nsf" +EXT_FILE_LOG: Final[str] = ".log" +EXT_FILE_WINDOWS_PROGRAM: Final[str] = ".exe" EXT_FILE_WAVE: Final[str] = ".wav" EXT_FILE_MP3: Final[str] = ".mp3" EXT_FILE_FLAC: Final[str] = ".flac" diff --git a/src/sampletones_tools/codec/report/corpus.py b/src/sampletones_tools/codec/report/corpus.py index 2255fc84d..b38156229 100644 --- a/src/sampletones_tools/codec/report/corpus.py +++ b/src/sampletones_tools/codec/report/corpus.py @@ -16,7 +16,7 @@ from sampletones_core.project.tuning import tuning_from_project from sampletones_core.project.voices.sample import Sample from sampletones_core.timers.utils import get_timer_table -from sampletones_core.timing import SongTiming +from sampletones_core.timing import SONG_TICK_BOUNDS, SongTiming from sampletones_player.builder import ( song_from_project, song_from_reconstruction, @@ -156,8 +156,9 @@ def lengthened_arrangement( Returns: Project: A copy of the project, its order repeated. """ - groove = SongTiming.from_project(project).groove() - frames = ceil(seconds * project.settings.nes_frequency / groove.total_ticks) + timing = SongTiming.from_project(project, bounds=SONG_TICK_BOUNDS) + pattern_ticks = timing.exact_row_ticks * project.song.rows_per_pattern + frames = ceil(seconds * project.settings.nes_frequency / pattern_ticks) return lengthened(project, frames) diff --git a/src/sampletones_tools/codec/study/corpus/projects.py b/src/sampletones_tools/codec/study/corpus/projects.py index 2a3af0b03..31aa6170b 100644 --- a/src/sampletones_tools/codec/study/corpus/projects.py +++ b/src/sampletones_tools/codec/study/corpus/projects.py @@ -7,7 +7,7 @@ from sampletones_core.project.project import Project from sampletones_core.project.tuning import tuning_from_project from sampletones_core.timers.utils import get_timer_table -from sampletones_core.timing import SongTiming +from sampletones_core.timing import SONG_TICK_BOUNDS, SongTiming from sampletones_player.builder import streams_from_instructions from sampletones_player.compression.pitch import PitchTable from sampletones_player.compression.planes.separate import planes_from_streams @@ -81,8 +81,8 @@ def _repeated( project: Project, seconds: int, ) -> Project: - groove = SongTiming.from_project(project).groove() - repetitions = max(1, ceil(seconds * project.settings.nes_frequency / groove.total_ticks)) + order_ticks = SongTiming.from_project(project, bounds=SONG_TICK_BOUNDS).frame_tick(project.song.order_length()) + repetitions = max(1, ceil(seconds * project.settings.nes_frequency / order_ticks)) longer = Project.create( rows_per_pattern=project.song.rows_per_pattern, settings=project.settings, diff --git a/src/sampletones_tools/console/__init__.py b/src/sampletones_tools/console/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_tools/console/cartridge.py b/src/sampletones_tools/console/cartridge.py new file mode 100644 index 000000000..53faa8c0f --- /dev/null +++ b/src/sampletones_tools/console/cartridge.py @@ -0,0 +1,73 @@ +from typing import Final, List + +from py65.memory import ObservableMemory + +from sampletones_player.specification.nsf import PROGRAM_START +from sampletones_tools.console.header import NSFHeader + +BANK_SIZE: Final[int] = 0x1000 +FIRST_BANK_REGISTER: Final[int] = 0x5FF8 +EMPTY_BYTE: Final[int] = 0x00 + + +class Cartridge: + """The program an NSF file maps into a console's memory, loaded whole or in switched banks. + + A file whose header names no bank loads its image whole at the load address. A banked file is + cut into 4 KB banks, the image padded at the front by the load address's place within its own + bank. Writing a bank's number to ``$5FF8`` + slot maps that bank into the slot's 4 KB of + ``$8000``-``$FFFF``, and the header names the bank every slot starts with. + """ + + def __init__(self, header: NSFHeader, image: bytes) -> None: + """Holds a file's image as its header says to map it. + + Args: + header: The file's header. + image: Everything the file holds behind its header. + """ + self._header = header + self._image = image + self._padded = bytes(header.load % BANK_SIZE) + image + + def mount(self, memory: ObservableMemory) -> None: + """Maps the program into a console's memory, and switches banks whenever the program asks. + + Args: + memory: The console's memory. + """ + if not self._header.banked: + memory.write(self._header.load, list(self._image)) + return + + for slot, bank in enumerate(self._header.banks): + self._map(memory, slot, bank) + + def switch(address: int, value: int) -> None: + self._map(memory, address - FIRST_BANK_REGISTER, value) + + memory.subscribe_to_write( + range(FIRST_BANK_REGISTER, FIRST_BANK_REGISTER + len(self._header.banks)), + switch, + ) + + def bank(self, number: int) -> List[int]: + """The 4 KB a bank holds, the part past the image's end reading as empty bytes. + + Args: + number: The bank, counted from the front of the padded image. + + Returns: + List[int]: The bank's bytes. + """ + start = number * BANK_SIZE + held = self._padded[start : start + BANK_SIZE] + return list(held) + [EMPTY_BYTE] * (BANK_SIZE - len(held)) + + def _map( + self, + memory: ObservableMemory, + slot: int, + bank: int, + ) -> None: + memory.write(PROGRAM_START + slot * BANK_SIZE, self.bank(bank)) diff --git a/src/sampletones_tools/console/errors.py b/src/sampletones_tools/console/errors.py new file mode 100644 index 000000000..3714cd9c5 --- /dev/null +++ b/src/sampletones_tools/console/errors.py @@ -0,0 +1,14 @@ +from sampletones_shared.exceptions import SampleToNESError +from sampletones_shared.exceptions.validation import InvalidDataError + + +class ConsoleError(SampleToNESError): + """The console could not run a file to the end of a routine.""" + + +class NotAnNSFError(InvalidDataError): + """The data a console was handed is no NSF file: its header lacks the NSF signature.""" + + +class RoutineOverrunError(ConsoleError): + """A routine ran for its whole step budget without returning to the caller.""" diff --git a/src/sampletones_tools/console/header.py b/src/sampletones_tools/console/header.py new file mode 100644 index 000000000..9e702b002 --- /dev/null +++ b/src/sampletones_tools/console/header.py @@ -0,0 +1,103 @@ +from dataclasses import dataclass +from typing import Final, Self, Tuple + +from sampletones_core.formats.binary import BinaryReader +from sampletones_player.specification.nsf import ( + BANKSWITCH_SIZE, + DUAL_REGION_FLAG, + NSF_MAGIC, + PAL_REGION_FLAG, + STRING_FIELD_SIZE, +) +from sampletones_tools.console.errors import NotAnNSFError + +VERSION_SIZE: Final[int] = 1 +STRING_FIELDS: Final[int] = 3 +NTSC_MACHINE: Final[int] = 0x00 +PAL_MACHINE: Final[int] = 0x01 +FIRST_SONG_NUMBER: Final[int] = 1 + + +@dataclass(frozen=True) +class NSFHeader: + """What an NSF player reads from a file's header before it runs the file. + + Attributes: + songs: How many songs the file holds. + first_song: The song a player starts with, counted from 1. + load: The address the image loads at. + init: The routine that starts a song. + play: The routine a player calls once per tick. + ntsc_period: The microseconds between play calls on an NTSC machine. + banks: The bank each 4 KB slot of ``$8000``-``$FFFF`` starts with, all zero for a file loaded whole. + pal_period: The microseconds between play calls on a PAL machine. + region: The machines the file plays on: the PAL flag, and the flag of a file made for both. + """ + + songs: int + first_song: int + load: int + init: int + play: int + ntsc_period: int + banks: Tuple[int, ...] + pal_period: int + region: int + + @classmethod + def read(cls, data: bytes) -> Self: + """Reads the header at the front of a whole NSF file, field by field as the format lays them out. + + Args: + data: The file, header first. + + Returns: + Self: The header. + + Raises: + NotAnNSFError: If the file opens with something other than the NSF signature. + TruncatedDataError: If the file ends inside the header. + """ + reader = BinaryReader(data) + signature = reader.read_bytes(len(NSF_MAGIC)) + if signature != NSF_MAGIC: + raise NotAnNSFError(f"The file opens with {signature!r}, not the NSF signature {NSF_MAGIC!r}") + + reader.skip(VERSION_SIZE) + songs = reader.read_uint8() + first_song = reader.read_uint8() + load = reader.read_uint16() + init = reader.read_uint16() + play = reader.read_uint16() + reader.skip(STRING_FIELD_SIZE * STRING_FIELDS) + ntsc_period = reader.read_uint16() + banks = tuple(reader.read_bytes(BANKSWITCH_SIZE)) + pal_period = reader.read_uint16() + region = reader.read_uint8() + return cls( + songs=songs, + first_song=first_song, + load=load, + init=init, + play=play, + ntsc_period=ntsc_period, + banks=banks, + pal_period=pal_period, + region=region, + ) + + @property + def banked(self) -> bool: + """Whether the file is read in switched banks, which a header naming any bank asks for.""" + return any(self.banks) + + @property + def first_song_index(self) -> int: + """The first song as the init routine is told it, counted from 0.""" + return self.first_song - FIRST_SONG_NUMBER + + @property + def machine(self) -> int: + """The machine the init routine is told it runs on: PAL for a file made for PAL alone, NTSC otherwise.""" + pal_only = bool(self.region & PAL_REGION_FLAG) and not self.region & DUAL_REGION_FLAG + return PAL_MACHINE if pal_only else NTSC_MACHINE diff --git a/src/sampletones_tools/console/machine.py b/src/sampletones_tools/console/machine.py new file mode 100644 index 000000000..f049e9b46 --- /dev/null +++ b/src/sampletones_tools/console/machine.py @@ -0,0 +1,141 @@ +from typing import Final, List, Tuple + +from py65.devices.mpu6502 import MPU +from py65.memory import ObservableMemory + +from sampletones_player.specification.nsf import HEADER_SIZE +from sampletones_player.specification.registers import ( + APU_FRAME_COUNTER, + FIRST_CHANNEL_REGISTER, +) +from sampletones_tools.console.cartridge import Cartridge +from sampletones_tools.console.errors import RoutineOverrunError +from sampletones_tools.console.header import NSFHeader +from sampletones_tools.player.trace.trace import RegisterTrace +from sampletones_tools.player.trace.write import RegisterWrite + +RETURN_SENTINEL: Final[int] = 0xFFF0 +STACK_PAGE: Final[int] = 0x0100 +STACK_TOP: Final[int] = 0xFF +ADDRESS_BITS: Final[int] = 8 +LOW_BYTE: Final[int] = 0xFF +PLAY_ACCUMULATOR: Final[int] = 0x00 +STEP_BUDGET: Final[int] = 1_000_000 + + +class Console: + """A 6502 running an NSF file the way an NSF player does, watching every APU register it writes. + + An NSF player maps the program behind the header, calls the init routine once with the song + in the accumulator and the machine in X, and calls the play routine once per tick. This runs + that sequence over py65's CPU with the APU's address range watched, so each routine answers + with the writes it made. The memory and the APU start at zero, as at power-up. + """ + + def __init__( + self, + data: bytes, + *, + step_budget: int = STEP_BUDGET, + ) -> None: + """Loads an NSF file the way a player loads it. + + Args: + data: The whole file, header included. + step_budget: The most instructions a routine may run before it must return. + + Raises: + NotAnNSFError: If the data opens with something other than the NSF signature. + TruncatedDataError: If the data ends inside the header. + """ + self.header = NSFHeader.read(data) + self._step_budget = step_budget + self._writes: List[RegisterWrite] = [] + self._memory = ObservableMemory() + Cartridge(self.header, data[HEADER_SIZE:]).mount(self._memory) + self._memory.subscribe_to_write( + range(FIRST_CHANNEL_REGISTER, APU_FRAME_COUNTER + 1), + self._observe, + ) + self._processor = MPU(memory=self._memory) + + def initialize(self) -> Tuple[RegisterWrite, ...]: + """Runs the init routine for the header's first song, on the machine the header names. + + Returns: + Tuple[RegisterWrite, ...]: Every APU register the routine wrote, in order. + + Raises: + RoutineOverrunError: If the routine runs past its step budget. + """ + return self._call( + self.header.init, + accumulator=self.header.first_song_index, + index=self.header.machine, + ) + + def play(self) -> Tuple[RegisterWrite, ...]: + """Runs one play call, the way the player calls it each tick. + + Returns: + Tuple[RegisterWrite, ...]: Every APU register the call wrote, in order. + + Raises: + RoutineOverrunError: If the routine runs past its step budget. + """ + return self._call( + self.header.play, + accumulator=PLAY_ACCUMULATOR, + index=self.header.machine, + ) + + def trace(self, play_calls: int) -> RegisterTrace: + """Runs a whole session: initialization followed by ``play_calls`` play calls. + + Args: + play_calls: How many play calls the run covers. + + Returns: + RegisterTrace: The writes of the initialization and of every play call. + + Raises: + RoutineOverrunError: If a routine runs past its step budget. + """ + initialization = self.initialize() + return RegisterTrace( + initialization=initialization, + play_calls=tuple(self.play() for _ in range(play_calls)), + ) + + def _observe(self, address: int, value: int) -> None: + self._writes.append(RegisterWrite(address, value)) + + def _seed_stack(self) -> None: + """Leaves the sentinel on the stack as a return address, so a routine's final RTS lands on it.""" + returned = RETURN_SENTINEL - 1 + self._memory[STACK_PAGE + STACK_TOP] = returned >> ADDRESS_BITS + self._memory[STACK_PAGE + STACK_TOP - 1] = returned & LOW_BYTE + self._processor.sp = STACK_TOP - 2 + + def _call( + self, + address: int, + *, + accumulator: int, + index: int, + ) -> Tuple[RegisterWrite, ...]: + self._writes = [] + self._processor.a = accumulator + self._processor.x = index + self._seed_stack() + self._processor.pc = address + + for _ in range(self._step_budget): + if self._processor.pc == RETURN_SENTINEL: + return tuple(self._writes) + + self._processor.step() + + raise RoutineOverrunError( + f"The routine at {address:#06x} ran for {self._step_budget} instructions without returning" + ) diff --git a/src/sampletones_tools/tracker_playback/command.py b/src/sampletones_tools/tracker_playback/command.py index bf46fda6c..4d32fe4a0 100644 --- a/src/sampletones_tools/tracker_playback/command.py +++ b/src/sampletones_tools/tracker_playback/command.py @@ -1,32 +1,50 @@ +from __future__ import annotations + from argparse import ArgumentParser, Namespace from dataclasses import dataclass from pathlib import Path -from typing import Dict, Final, Optional, Tuple +from typing import TYPE_CHECKING, Dict, Final, List, Optional, Tuple from sampletones_shared.command import Command from sampletones_tools.tracker_playback.commands.bitphase import BITPHASE from sampletones_tools.tracker_playback.commands.face import TargetFace +from sampletones_tools.tracker_playback.commands.famitracker import FAMITRACKER + +if TYPE_CHECKING: + from sampletones_tools.tracker_playback.projects import CheckedProject NAME: Final[str] = "tracker-playback" HELP: Final[str] = ( - "export a corpus of projects to a tracker, play each file with the tracker's own code, " + "export projects to a tracker, play each file with the tracker's own code, " "and report every tick a channel sounds differently from the app" ) TARGET_FIELD: Final[str] = "target" TARGET_METAVAR: Final[str] = "" +PROJECT_HELP: Final[str] = ( + "a project file (.stp) to check; repeat it to check several. Without it, the run checks the corpus " + "that comes with the package" +) +PROJECT_METAVAR: Final[str] = "" OUTPUT_HELP: Final[str] = ( "the directory the run writes into; without it, a timestamped directory under " "Documents/SampleToNES/tracker-playback" ) -TARGETS: Final[Tuple[TargetFace, ...]] = (BITPHASE,) +TARGETS: Final[Tuple[TargetFace, ...]] = (BITPHASE, FAMITRACKER) TARGETS_BY_NAME: Final[Dict[str, TargetFace]] = {face.name: face for face in TARGETS} @dataclass(frozen=True) class PlaybackArguments: - """What a check is given beside its target's own options: the target, and where it writes, if anywhere.""" + """What a check is given beside its target's own options. + + Attributes: + target: The target's name. + projects: The project files to check, or none to check the corpus. + output: Where the run writes, or ``None`` for a timestamped directory. + """ target: str + projects: Tuple[Path, ...] output: Optional[Path] @@ -43,6 +61,15 @@ def configure(parser: ArgumentParser) -> None: description=face.help, ) face.configure(target) + target.add_argument( + "--project", + dest="projects", + type=Path, + action="append", + default=None, + metavar=PROJECT_METAVAR, + help=PROJECT_HELP, + ) target.add_argument( "--output", "-o", @@ -53,20 +80,25 @@ def configure(parser: ArgumentParser) -> None: def run(arguments: Namespace) -> int: - """Plays the corpus through the target the command names, prints each verdict and the report's link. + """Plays the projects given, or the corpus, through the target the command names, and prints each verdict. + + The report's link follows the verdicts. Raises: - SystemExit: If the target's tracker, or what runs it, is missing, or it fails to play a file. + SystemExit: If a project file fails to open, the target's tracker or what runs it is missing, or + the tracker fails to play a file. """ - given = PlaybackArguments(target=arguments.target, output=arguments.output) + given = PlaybackArguments( + target=arguments.target, + projects=tuple(arguments.projects) if arguments.projects is not None else (), + output=arguments.output, + ) face = TARGETS_BY_NAME[given.target] - from sampletones_tools.tracker_playback.corpus.build import comparison_corpus - from sampletones_tools.tracker_playback.corpus.spec import CorpusSpec from sampletones_tools.tracker_playback.report import result_label from sampletones_tools.tracker_playback.session import ( PlaybackRun, - check_corpus, + check_projects, default_output, ) from sampletones_tools.tracker_playback.settings import PlaybackSettings @@ -74,8 +106,8 @@ def run(arguments: Namespace) -> int: try: target = face.locate(arguments) - outcome = check_corpus( - comparison_corpus(CorpusSpec.load()), + outcome = check_projects( + checked_projects(given.projects), PlaybackRun( target=target, output=given.output if given.output is not None else default_output(), @@ -92,6 +124,26 @@ def run(arguments: Namespace) -> int: return 0 +def checked_projects(paths: Tuple[Path, ...]) -> List[CheckedProject]: + """The projects saved in ``paths``, or the corpus that comes with the package where none is given. + + Raises: + SystemExit: If a project file fails to open, naming why. + """ + from sampletones_shared.exceptions.project import LoadProjectError + from sampletones_tools.tracker_playback.corpus.build import comparison_corpus + from sampletones_tools.tracker_playback.corpus.spec import CorpusSpec + from sampletones_tools.tracker_playback.projects import loaded_projects + + if not paths: + return comparison_corpus(CorpusSpec.load()) + + try: + return loaded_projects(paths) + except (LoadProjectError, OSError) as error: + raise SystemExit(str(error)) from error + + TRACKER_PLAYBACK: Final[Command] = Command( name=NAME, help=HELP, diff --git a/src/sampletones_tools/tracker_playback/commands/famitracker.py b/src/sampletones_tools/tracker_playback/commands/famitracker.py new file mode 100644 index 000000000..548c85134 --- /dev/null +++ b/src/sampletones_tools/tracker_playback/commands/famitracker.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from argparse import ArgumentParser, Namespace +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Final + +from sampletones_tools.tracker_playback.commands.face import TargetFace + +if TYPE_CHECKING: + from sampletones_tools.tracker_playback.targets.protocol import PlaybackTarget + +NAME: Final[str] = "famitracker" +HELP: Final[str] = ( + "export each project to a FamiTracker module (.ftm), have FamiTracker export it to an NSF, " + "and play the NSF's own driver on an emulated 6502" +) +EXECUTABLE_HELP: Final[str] = "FamiTracker.exe, which Wine runs on Linux and macOS" + + +@dataclass(frozen=True) +class FamiTrackerArguments: + """What the FamiTracker target is given: the program that exports the modules.""" + + executable: Path + + +def configure(parser: ArgumentParser) -> None: + parser.add_argument( + "--executable", + type=Path, + required=True, + help=EXECUTABLE_HELP, + ) + + +def locate(arguments: Namespace) -> PlaybackTarget: + """The FamiTracker target exporting with the program the options name. + + Raises: + FamiTrackerError: If the program is no Windows program file, or Wine is absent where it is needed. + """ + given = FamiTrackerArguments(executable=arguments.executable) + + from sampletones_tools.tracker_playback.targets.famitracker.target import FamiTrackerTarget + + return FamiTrackerTarget.located(given.executable) + + +FAMITRACKER: Final[TargetFace] = TargetFace( + name=NAME, + help=HELP, + configure=configure, + locate=locate, +) diff --git a/src/sampletones_tools/tracker_playback/corpus/build.py b/src/sampletones_tools/tracker_playback/corpus/build.py index dff434b01..f0338b3d7 100644 --- a/src/sampletones_tools/tracker_playback/corpus/build.py +++ b/src/sampletones_tools/tracker_playback/corpus/build.py @@ -1,4 +1,3 @@ -from dataclasses import dataclass from typing import List from sampletones_core.project.project import Project @@ -6,27 +5,13 @@ from sampletones_tools.samples.bitphase import at_tempo from sampletones_tools.tracker_playback.corpus.spec import ArrangementSpec, CorpusSpec, ProjectSpec from sampletones_tools.tracker_playback.corpus.voices import build_voice - - -@dataclass(frozen=True) -class CorpusProject: - """One project a tracker playback check plays, with what it exercises. - - Attributes: - name: The name its files are written under. - purpose: What the project exercises, in one sentence. - project: The project itself. - """ - - name: str - purpose: str - project: Project +from sampletones_tools.tracker_playback.projects import CheckedProject def written_project( spec: ProjectSpec, corpus: CorpusSpec, -) -> CorpusProject: +) -> CheckedProject: """The project a spec writes out, holding fresh copies of the corpus voices it lists. Args: @@ -34,14 +19,14 @@ def written_project( corpus: The corpus whose voices the project draws on. Returns: - CorpusProject: The project, with its name and purpose. + CheckedProject: The project, with its name and purpose. Raises: KeyError: If the project lists a voice the corpus lacks, or a row names a voice the project leaves out. """ voices = {name: build_voice(name, corpus.voices[name]) for name in spec.voices} - return CorpusProject( + return CheckedProject( name=spec.name, purpose=spec.purpose, project=build_project( @@ -55,7 +40,7 @@ def written_project( def arrangement_project( spec: ArrangementSpec, arrangement: Project, -) -> CorpusProject: +) -> CheckedProject: """The synthetic corpus arrangement played at the tempo a spec names. Args: @@ -63,9 +48,9 @@ def arrangement_project( arrangement: The arrangement as the synthetic corpus builds it. Returns: - CorpusProject: The arrangement at that tempo. + CheckedProject: The arrangement at that tempo. """ - return CorpusProject( + return CheckedProject( name=spec.name, purpose=spec.purpose, project=at_tempo( @@ -75,14 +60,14 @@ def arrangement_project( ) -def comparison_corpus(corpus: CorpusSpec) -> List[CorpusProject]: +def comparison_corpus(corpus: CorpusSpec) -> List[CheckedProject]: """Every project a tracker playback check plays: those written out, then the reconstructed arrangement. Args: corpus: The corpus to build. Returns: - List[CorpusProject]: The written projects in the order the corpus lists them, then the + List[CheckedProject]: The written projects in the order the corpus lists them, then the arrangement at each of its tempi. """ written = [written_project(spec, corpus) for spec in corpus.projects] diff --git a/src/sampletones_tools/tracker_playback/outcome.py b/src/sampletones_tools/tracker_playback/outcome.py index c6978ff3e..e7a8b695d 100644 --- a/src/sampletones_tools/tracker_playback/outcome.py +++ b/src/sampletones_tools/tracker_playback/outcome.py @@ -1,23 +1,24 @@ from dataclasses import dataclass -from typing import Optional +from typing import Optional, Tuple +from sampletones_core.exporters.skipped import SkippedRow from sampletones_core.exporters.truncation import EnvelopeTruncation from sampletones_tools.tracker_playback.comparison import TraceComparison -from sampletones_tools.tracker_playback.corpus.build import CorpusProject +from sampletones_tools.tracker_playback.projects import CheckedProject @dataclass(frozen=True) class ProjectOutcome: - """How one project of the corpus fared: what the tracker played of it, and what its export left out. + """How one checked project fared: what the tracker played of it, and what its export left out. Attributes: project: The project compared. comparison: How the tracker played it against the application. - skipped_rows: How many rows the export wrote as note cuts for lack of an instrument. + skipped_rows: The rows the export wrote other than the song plays them, each with why. truncation: The instruments a macro shortened, or ``None`` where every one fit. """ - project: CorpusProject + project: CheckedProject comparison: TraceComparison - skipped_rows: int + skipped_rows: Tuple[SkippedRow, ...] truncation: Optional[EnvelopeTruncation] diff --git a/src/sampletones_tools/tracker_playback/projects.py b/src/sampletones_tools/tracker_playback/projects.py new file mode 100644 index 000000000..859d329df --- /dev/null +++ b/src/sampletones_tools/tracker_playback/projects.py @@ -0,0 +1,76 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Final, List, Sequence, Set + +from sampletones_core.project.container import ProjectContainer +from sampletones_core.project.project import Project + +LOADED_PURPOSE: Final[str] = "The project saved in `{path}`." +COUNTED_NAME: Final[str] = "{stem}-{count}" +FIRST_COUNT: Final[int] = 2 + + +@dataclass(frozen=True) +class CheckedProject: + """One project a tracker playback check plays, with what it is. + + Attributes: + name: The name its files are written under. + purpose: What the project exercises, or the file it was loaded from, in one sentence. + project: The project itself. + """ + + name: str + purpose: str + project: Project + + +def loaded_projects(paths: Sequence[Path]) -> List[CheckedProject]: + """The projects saved in the files a check is given, each named after its file. + + A file whose name an earlier file already took is named with a count after it, so the files each + project writes stay apart. + + Args: + paths: The project files, in the order the check plays them. + + Returns: + List[CheckedProject]: One project per file, in the same order. + + Raises: + LoadProjectError: If a file holds no project the application opens. + OSError: If a file can't be read. + """ + taken: Set[str] = set() + projects: List[CheckedProject] = [] + for path in paths: + name = unique_name(path.stem, taken) + taken.add(name) + projects.append( + CheckedProject( + name=name, + purpose=LOADED_PURPOSE.format(path=path.resolve()), + project=ProjectContainer.load(path), + ) + ) + + return projects + + +def unique_name(stem: str, taken: Set[str]) -> str: + """``stem``, or ``stem`` with the lowest count from ``FIRST_COUNT`` up that no project took yet. + + Args: + stem: The name the file gives. + taken: The names earlier projects took. + + Returns: + str: A name none of them has. + """ + name = stem + count = FIRST_COUNT + while name in taken: + name = COUNTED_NAME.format(stem=stem, count=count) + count += 1 + + return name diff --git a/src/sampletones_tools/tracker_playback/report.py b/src/sampletones_tools/tracker_playback/report.py index c56f260ea..05aed0518 100644 --- a/src/sampletones_tools/tracker_playback/report.py +++ b/src/sampletones_tools/tracker_playback/report.py @@ -1,6 +1,8 @@ -from typing import Final, List, Sequence, Tuple +from collections import Counter +from typing import Dict, Final, List, Sequence, Tuple from sampletones_core.constants.enums import ChannelName +from sampletones_core.exporters.skipped import SkipReason from sampletones_shared.utils.tables import Table from sampletones_tools.tracker_playback.comparison import ( Divergence, @@ -12,7 +14,7 @@ from sampletones_tools.tracker_playback.targets.protocol import PlaybackTarget from sampletones_tools.tracker_playback.trace.sound import ChannelSound -TITLE: Final[str] = "# What {tracker} plays of the corpus" +TITLE: Final[str] = "# What {tracker} plays of each project" INTRODUCTION: Final[str] = ( "Each project was exported to {tracker} with the application's own exporter and played by {player}. Every " "engine tick of every channel is held against what the application plays, both read out of the registers " @@ -39,7 +41,15 @@ "and the tracker at frame {engine_frame}, row {engine_row}." ) ALL_ALIKE: Final[str] = "Every tick sounds alike." -SKIPPED_ROWS: Final[str] = "Rows the export wrote as note cuts, for lack of an instrument on their channel: {count}." +SKIPPED_ROWS: Final[Dict[SkipReason, str]] = { + SkipReason.NO_INSTRUMENT: ( + "Rows the export wrote as note cuts, for lack of an instrument on their channel: {count}." + ), + SkipReason.UNREACHED_TRANSPOSE: ( + "Transpose rows the export wrote without their pitch change, " + "because the format can't make it there: {count}." + ), +} SHORTENED: Final[str] = ( "Instruments the export shortened to {frames} of their {source_frames} frames, " "the most the format holds: {instruments}." @@ -171,9 +181,8 @@ def export_notes(outcome: ProjectOutcome) -> List[str]: Returns: List[str]: One line per thing left out. """ - notes: List[str] = [] - if outcome.skipped_rows: - notes.append(SKIPPED_ROWS.format(count=outcome.skipped_rows)) + reasons = Counter(row.reason for row in outcome.skipped_rows) + notes = [SKIPPED_ROWS[reason].format(count=reasons[reason]) for reason in SkipReason if reasons[reason]] truncation = outcome.truncation if truncation is not None: diff --git a/src/sampletones_tools/tracker_playback/session.py b/src/sampletones_tools/tracker_playback/session.py index 9ec8a3b84..f5fe4a40f 100644 --- a/src/sampletones_tools/tracker_playback/session.py +++ b/src/sampletones_tools/tracker_playback/session.py @@ -4,13 +4,13 @@ from sampletones_tools.runs import stamped_run_directory from sampletones_tools.tracker_playback.comparison import compare_traces -from sampletones_tools.tracker_playback.corpus.build import CorpusProject from sampletones_tools.tracker_playback.outcome import ProjectOutcome from sampletones_tools.tracker_playback.paths import ( DOCUMENTS_DIRECTORY_NAME, OUTPUT_ROOT, REPORT_FILENAME, ) +from sampletones_tools.tracker_playback.projects import CheckedProject from sampletones_tools.tracker_playback.report import report_text from sampletones_tools.tracker_playback.settings import PlaybackSettings from sampletones_tools.tracker_playback.targets.protocol import PlaybackTarget @@ -19,7 +19,7 @@ @dataclass(frozen=True) class PlaybackRun: - """What a check plays the corpus through, where it writes, and how it reports. + """What a check plays its projects through, where it writes, and how it reports. Attributes: target: The tracker the projects are exported to and played by. @@ -51,13 +51,13 @@ def default_output() -> Path: def check_project( - corpus_project: CorpusProject, + checked: CheckedProject, run: PlaybackRun, ) -> ProjectOutcome: """Plays one project through the target and holds what it played against the application. Args: - corpus_project: The project to check. + checked: The project to check. run: What plays it, where the files go, and how differences are shown. Returns: @@ -67,14 +67,14 @@ def check_project( PlaybackError: If the tracker fails to play the project. """ playback = run.target.play( - corpus_project.project, + checked.project, run.output / DOCUMENTS_DIRECTORY_NAME, - corpus_project.name, + checked.name, ) return ProjectOutcome( - project=corpus_project, + project=checked, comparison=compare_traces( - application_trace(corpus_project.project), + application_trace(checked.project), playback.trace, examples=run.settings.examples_per_difference, ), @@ -83,11 +83,11 @@ def check_project( ) -def check_corpus( - projects: Sequence[CorpusProject], +def check_projects( + projects: Sequence[CheckedProject], run: PlaybackRun, ) -> PlaybackOutcome: - """Checks every project of a corpus and writes the report on all of them. + """Checks each project and writes the report on all of them. The output keeps every exported file, and whatever its playing wrote, beside the report, so a difference can be followed into the file and the ticks that show it. @@ -103,7 +103,7 @@ def check_corpus( PlaybackError: If the tracker fails to play a project. """ (run.output / DOCUMENTS_DIRECTORY_NAME).mkdir(parents=True, exist_ok=True) - outcomes = tuple(check_project(corpus_project, run) for corpus_project in projects) + outcomes = tuple(check_project(checked, run) for checked in projects) report = run.output / REPORT_FILENAME report.write_text( report_text( diff --git a/src/sampletones_tools/tracker_playback/targets/bitphase/target.py b/src/sampletones_tools/tracker_playback/targets/bitphase/target.py index 745517416..f804c7258 100644 --- a/src/sampletones_tools/tracker_playback/targets/bitphase/target.py +++ b/src/sampletones_tools/tracker_playback/targets/bitphase/target.py @@ -82,6 +82,6 @@ def play( document, directory / f"{name}{EXT_FILE_JSON}", ), - skipped_rows=len(built.skipped_rows), + skipped_rows=built.skipped_rows, truncation=built.truncation, ) diff --git a/src/sampletones_tools/tracker_playback/targets/famitracker/__init__.py b/src/sampletones_tools/tracker_playback/targets/famitracker/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_tools/tracker_playback/targets/famitracker/errors.py b/src/sampletones_tools/tracker_playback/targets/famitracker/errors.py new file mode 100644 index 000000000..6461e945f --- /dev/null +++ b/src/sampletones_tools/tracker_playback/targets/famitracker/errors.py @@ -0,0 +1,5 @@ +from sampletones_tools.tracker_playback.targets.protocol import PlaybackError + + +class FamiTrackerError(PlaybackError): + """FamiTracker could not play a project: it or Wine is missing, its export failed, or its NSF failed to run.""" diff --git a/src/sampletones_tools/tracker_playback/targets/famitracker/export.py b/src/sampletones_tools/tracker_playback/targets/famitracker/export.py new file mode 100644 index 000000000..3a43c73fc --- /dev/null +++ b/src/sampletones_tools/tracker_playback/targets/famitracker/export.py @@ -0,0 +1,56 @@ +import subprocess +from pathlib import Path +from typing import Final + +from sampletones_tools.tracker_playback.targets.famitracker.errors import FamiTrackerError +from sampletones_tools.tracker_playback.targets.famitracker.program.protocol import FamiTrackerProgram + +EXPORT_TIMEOUT_SECONDS: Final[int] = 120 +NO_LOG: Final[str] = "FamiTracker wrote no log." + + +def export_nsf( + program: FamiTrackerProgram, + module: Path, + nsf: Path, + log: Path, +) -> None: + """Has FamiTracker export a module to an NSF from its command line. + + FamiTracker quits the same way whether the export worked or not, so the NSF it leaves is what + tells the two apart. A file at ``nsf`` from an earlier run is removed first. + + Args: + program: FamiTracker, as this system runs it. + module: The `.ftm` module to export. + nsf: Where the NSF is written. + log: Where FamiTracker writes what it did. + + Raises: + FamiTrackerError: If FamiTracker writes no NSF or runs past ``EXPORT_TIMEOUT_SECONDS``, with its log. + """ + nsf.unlink(missing_ok=True) + try: + completed = subprocess.run( + program.export_command(module, nsf, log), + env=program.environment(), + capture_output=True, + text=True, + timeout=EXPORT_TIMEOUT_SECONDS, + check=False, + ) + except subprocess.TimeoutExpired as error: + raise FamiTrackerError( + f"FamiTracker ran for more than {EXPORT_TIMEOUT_SECONDS} seconds exporting {module}" + ) from error + + if not nsf.is_file(): + raise FamiTrackerError(f"FamiTracker wrote no NSF for {module}.\n{log_text(log)}\n{completed.stderr}".rstrip()) + + +def log_text(log: Path) -> str: + """What FamiTracker wrote to its log, or ``NO_LOG`` where it wrote none.""" + if not log.is_file(): + return NO_LOG + + return log.read_text(encoding="utf-8", errors="replace").strip() diff --git a/src/sampletones_tools/tracker_playback/targets/famitracker/markers.py b/src/sampletones_tools/tracker_playback/targets/famitracker/markers.py new file mode 100644 index 000000000..0773c9e5b --- /dev/null +++ b/src/sampletones_tools/tracker_playback/targets/famitracker/markers.py @@ -0,0 +1,83 @@ +from typing import Final, Tuple + +from sampletones_core.formats.famitracker.model.module import FamiTrackerModule, OrderFrame +from sampletones_core.formats.famitracker.model.pattern import PatternData, RowCell +from sampletones_core.formats.famitracker.specification.channels import ChannelId +from sampletones_core.formats.famitracker.specification.patterns import ( + EMPTY_INSTRUMENT, + EMPTY_NOTE, + EMPTY_VOLUME, + MIN_OCTAVE, + EffectId, +) + +MARKER_LEVELS: Final[int] = 4 + + +def row_marker(row_index: int) -> int: + """The level the marker of a row loads: its place in the song, wrapped into ``MARKER_LEVELS``. + + Two rows in a row always carry different levels, so every row's marker is a new write. The + DMC's level shifts how loud the triangle and the noise sound in the mix, and a jump between + levels clicks, so low levels keep both slight. + + Args: + row_index: The row's place in one pass through the song. + + Returns: + int: The level, below ``MARKER_LEVELS``. + """ + return row_index % MARKER_LEVELS + + +def marked_module(module: FamiTrackerModule) -> FamiTrackerModule: + """The module with a row marker on every row of its DPCM channel, the channel the application's export leaves empty. + + Each order frame plays a DPCM pattern of its own, and each of its rows loads the DMC's output + level with that row's marker. FamiTracker writes the level to ``$4011`` on the tick it plays the + row, so the NSF it exports says where every row starts. The other channels stay as they are. + + Args: + module: The module the application's export built. + + Returns: + FamiTrackerModule: The module with the markers. + """ + track = module.track + rows = track.rows_per_pattern + patterns = tuple(pattern for pattern in track.patterns if pattern.channel != ChannelId.DPCM) + markers = tuple(_marker_pattern(frame, rows) for frame in range(len(track.order))) + return module.model_copy( + update={ + "track": track.model_copy( + update={ + "order": tuple(_marked_frame(frame, entries) for frame, entries in enumerate(track.order)), + "patterns": patterns + markers, + } + ) + } + ) + + +def _marked_frame(frame: int, entries: OrderFrame) -> OrderFrame: + return tuple(frame if channel == ChannelId.DPCM else index for channel, index in zip(ChannelId, entries)) + + +def _marker_pattern(frame: int, rows: int) -> PatternData: + return PatternData( + channel=ChannelId.DPCM, + index=frame, + rows=tuple(_marker_cell(row, row_marker(frame * rows + row)) for row in range(rows)), + ) + + +def _marker_cell(row: int, level: int) -> RowCell: + effects: Tuple[Tuple[int, int], ...] = ((int(EffectId.DAC), level),) + return RowCell( + row_number=row, + note=EMPTY_NOTE, + octave=MIN_OCTAVE, + instrument=EMPTY_INSTRUMENT, + volume=EMPTY_VOLUME, + effects=effects, + ) diff --git a/src/sampletones_tools/tracker_playback/targets/famitracker/program/__init__.py b/src/sampletones_tools/tracker_playback/targets/famitracker/program/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_tools/tracker_playback/targets/famitracker/program/factory.py b/src/sampletones_tools/tracker_playback/targets/famitracker/program/factory.py new file mode 100644 index 000000000..cf80f3ad5 --- /dev/null +++ b/src/sampletones_tools/tracker_playback/targets/famitracker/program/factory.py @@ -0,0 +1,33 @@ +from pathlib import Path + +from sampletones_shared.paths.extensions import EXT_FILE_WINDOWS_PROGRAM +from sampletones_shared.utils.system.system import System +from sampletones_tools.tracker_playback.targets.famitracker.errors import FamiTrackerError +from sampletones_tools.tracker_playback.targets.famitracker.program.native import NativeProgram +from sampletones_tools.tracker_playback.targets.famitracker.program.protocol import FamiTrackerProgram +from sampletones_tools.tracker_playback.targets.famitracker.program.wine import WineProgram + + +def located_program(executable: Path) -> FamiTrackerProgram: + """FamiTracker at ``executable``, run the way this system runs a Windows program. + + Windows runs it directly, and Linux and macOS run it through Wine. + + Args: + executable: The FamiTracker program file, ``FamiTracker.exe``. + + Returns: + FamiTrackerProgram: The program. + + Raises: + FamiTrackerError: If ``executable`` is no Windows program file, or Wine is absent where it is needed. + OSError: If the system is unsupported. + """ + if not executable.is_file() or executable.suffix.lower() != EXT_FILE_WINDOWS_PROGRAM: + raise FamiTrackerError(f"{executable} is no Windows program file. Give the path to FamiTracker.exe itself.") + + match System.current(): + case System.WINDOWS: + return NativeProgram(executable=executable) + case System.LINUX | System.MACOS: + return WineProgram.located(executable) diff --git a/src/sampletones_tools/tracker_playback/targets/famitracker/program/native.py b/src/sampletones_tools/tracker_playback/targets/famitracker/program/native.py new file mode 100644 index 000000000..93c18eb22 --- /dev/null +++ b/src/sampletones_tools/tracker_playback/targets/famitracker/program/native.py @@ -0,0 +1,48 @@ +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, List + +from sampletones_tools.tracker_playback.targets.famitracker.program.protocol import EXPORT_SWITCH + + +@dataclass(frozen=True) +class NativeProgram: + """FamiTracker run directly, the way Windows runs it. + + FamiTracker reads an argument starting with ``/`` or ``-`` as a switch, so every path is given + absolute: on Windows that opens with a drive letter. + + Attributes: + executable: The FamiTracker program file. + """ + + executable: Path + + def export_command( + self, + module: Path, + nsf: Path, + log: Path, + ) -> List[str]: + """The command that exports a module to an NSF and writes FamiTracker's log. + + Args: + module: The `.ftm` module to export. + nsf: Where the NSF is written. + log: Where FamiTracker writes what it did. + + Returns: + List[str]: The program and its arguments. + """ + return [ + str(self.executable.resolve()), + str(module.resolve()), + EXPORT_SWITCH, + str(nsf.resolve()), + str(log.resolve()), + ] + + def environment(self) -> Dict[str, str]: + """The environment the export runs in: the one the check runs in.""" + return dict(os.environ) diff --git a/src/sampletones_tools/tracker_playback/targets/famitracker/program/protocol.py b/src/sampletones_tools/tracker_playback/targets/famitracker/program/protocol.py new file mode 100644 index 000000000..e842be57e --- /dev/null +++ b/src/sampletones_tools/tracker_playback/targets/famitracker/program/protocol.py @@ -0,0 +1,40 @@ +from pathlib import Path +from typing import Dict, Final, List, Protocol + +EXPORT_SWITCH: Final[str] = "-export" + + +class FamiTrackerProgram(Protocol): + """FamiTracker as this system runs it, so its command-line export turns a module into an NSF. + + FamiTracker exports from its command line, ``FamiTracker.exe -export ``, + writes what it did to the log and quits with no window shown. Each system reaches the Windows + program its own way, and each way is one implementation. + """ + + @property + def executable(self) -> Path: + """The FamiTracker program file.""" + + def export_command( + self, + module: Path, + nsf: Path, + log: Path, + ) -> List[str]: + """The command that exports a module to an NSF and writes FamiTracker's log. + + Args: + module: The `.ftm` module to export. + nsf: Where the NSF is written. + log: Where FamiTracker writes what it did. + + Returns: + List[str]: The program and its arguments. + + Raises: + FamiTrackerError: If the paths cannot be put in the form FamiTracker reads. + """ + + def environment(self) -> Dict[str, str]: + """The environment the export runs in.""" diff --git a/src/sampletones_tools/tracker_playback/targets/famitracker/program/wine.py b/src/sampletones_tools/tracker_playback/targets/famitracker/program/wine.py new file mode 100644 index 000000000..fe857e325 --- /dev/null +++ b/src/sampletones_tools/tracker_playback/targets/famitracker/program/wine.py @@ -0,0 +1,123 @@ +import os +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Final, List, Self, Sequence, Tuple + +from sampletones_shared.utils.system.programs import locate_program, missing_program_message +from sampletones_shared.utils.system.system import System +from sampletones_tools.tracker_playback.targets.famitracker.errors import FamiTrackerError +from sampletones_tools.tracker_playback.targets.famitracker.program.protocol import EXPORT_SWITCH + +WINE: Final[str] = "wine" +WINE_PURPOSE: Final[str] = "FamiTracker is a Windows program, and Wine runs it on this system" +WINEPATH: Final[str] = "winepath" +WINDOWS_PATH_SWITCH: Final[str] = "-w" +WINE_DEBUG: Final[str] = "WINEDEBUG" +WINE_DEBUG_SILENT: Final[str] = "-all" +DISPLAY_VARIABLES: Final[Tuple[str, ...]] = ("DISPLAY", "WAYLAND_DISPLAY") +INSTALL_HINTS: Final[Dict[System, str]] = { + System.LINUX: "sudo apt install wine", + System.MACOS: "brew install --cask wine-stable", +} + + +@dataclass(frozen=True) +class WineProgram: + """FamiTracker run through Wine, the way Linux and macOS run a Windows program. + + FamiTracker reads an argument starting with ``/`` as a switch, so a path reaches it as the + Windows path Wine maps it to, which Wine's own ``winepath`` gives. The export shows no window, + so it runs with no display and with Wine's diagnostics silenced, and a run leaves the desktop + as it was. + + Attributes: + wine: The wine program. + executable: The FamiTracker program file. + """ + + wine: Path + executable: Path + + @classmethod + def located(cls, executable: Path) -> Self: + """FamiTracker at ``executable``, run by the Wine this system has. + + Args: + executable: The FamiTracker program file. + + Returns: + Self: The program. + + Raises: + FamiTrackerError: If Wine is absent, naming how this system installs it. + """ + wine = locate_program(WINE) + if wine is None: + raise FamiTrackerError(missing_program_message(WINE, WINE_PURPOSE, INSTALL_HINTS)) + + return cls(wine=wine, executable=executable) + + def export_command( + self, + module: Path, + nsf: Path, + log: Path, + ) -> List[str]: + """The command that exports a module to an NSF and writes FamiTracker's log, each path as Wine maps it. + + Args: + module: The `.ftm` module to export. + nsf: Where the NSF is written. + log: Where FamiTracker writes what it did. + + Returns: + List[str]: The program and its arguments. + + Raises: + FamiTrackerError: If Wine fails to map the paths. + """ + windows_module, windows_nsf, windows_log = self.windows_paths((module, nsf, log)) + return [ + str(self.wine), + str(self.executable), + windows_module, + EXPORT_SWITCH, + windows_nsf, + windows_log, + ] + + def windows_paths(self, paths: Sequence[Path]) -> Tuple[str, ...]: + """The Windows path Wine maps each path to, asked of Wine in one call. + + Args: + paths: The paths, absolute or relative to the working directory. + + Returns: + Tuple[str, ...]: One Windows path per path, in the same order. + + Raises: + FamiTrackerError: If Wine fails to map them. + """ + try: + completed = subprocess.run( + [str(self.wine), WINEPATH, WINDOWS_PATH_SWITCH, *(str(path.resolve()) for path in paths)], + env=self.environment(), + capture_output=True, + text=True, + check=True, + ) + except subprocess.CalledProcessError as error: + raise FamiTrackerError(f"Wine failed to map the paths it hands FamiTracker:\n{error.stderr}") from error + + mapped = tuple(completed.stdout.splitlines()) + if len(mapped) != len(paths): + raise FamiTrackerError(f"Wine mapped {len(paths)} paths to {len(mapped)}: {completed.stdout!r}") + + return mapped + + def environment(self) -> Dict[str, str]: + """The environment the export runs in: the check's own, with no display and Wine's diagnostics silenced.""" + environment = {name: value for name, value in os.environ.items() if name not in DISPLAY_VARIABLES} + environment[WINE_DEBUG] = WINE_DEBUG_SILENT + return environment diff --git a/src/sampletones_tools/tracker_playback/targets/famitracker/target.py b/src/sampletones_tools/tracker_playback/targets/famitracker/target.py new file mode 100644 index 000000000..d4c1a8356 --- /dev/null +++ b/src/sampletones_tools/tracker_playback/targets/famitracker/target.py @@ -0,0 +1,131 @@ +from dataclasses import dataclass +from pathlib import Path +from tempfile import TemporaryDirectory +from typing import Final, Self + +from sampletones_core.exporters.skipped import BuiltDocument +from sampletones_core.formats.famitracker.builder import build_module +from sampletones_core.formats.famitracker.model.module import FamiTrackerModule +from sampletones_core.formats.famitracker.module import module_to_ftm_bytes +from sampletones_core.project.project import Project +from sampletones_shared.exceptions.validation import InvalidDataError +from sampletones_shared.paths.extensions import EXT_FILE_JSON, EXT_FILE_LOG, EXT_FILE_MODULE, EXT_FILE_NSF +from sampletones_tools.console.errors import ConsoleError +from sampletones_tools.console.machine import Console +from sampletones_tools.tracker_playback.targets.famitracker.errors import FamiTrackerError +from sampletones_tools.tracker_playback.targets.famitracker.export import export_nsf +from sampletones_tools.tracker_playback.targets.famitracker.markers import marked_module +from sampletones_tools.tracker_playback.targets.famitracker.program.factory import located_program +from sampletones_tools.tracker_playback.targets.famitracker.program.protocol import FamiTrackerProgram +from sampletones_tools.tracker_playback.targets.famitracker.trace import DriverTrace, recorded_trace +from sampletones_tools.tracker_playback.targets.protocol import TargetPlayback + +TITLE: Final[str] = "FamiTracker" +PLAYER: Final[str] = "FamiTracker's own sound driver, in the NSF `{executable}` exported, run on an emulated 6502" + + +@dataclass(frozen=True) +class FamiTrackerTarget: + """FamiTracker as a playback target: a project exported to a module, which FamiTracker exports to an NSF. + + The NSF carries FamiTracker's own sound driver, the code a console or an NSF player runs, so + running it on an emulated 6502 plays the module the way FamiTracker's exports play it. + + Attributes: + program: FamiTracker, as this system runs it. + """ + + program: FamiTrackerProgram + + @classmethod + def located(cls, executable: Path) -> Self: + """The target exporting with FamiTracker at ``executable``. + + Args: + executable: The FamiTracker program file. + + Returns: + Self: The target. + + Raises: + FamiTrackerError: If ``executable`` is no Windows program file, or Wine is absent where it is needed. + """ + return cls(program=located_program(executable)) + + @property + def title(self) -> str: + """The tracker's name, as the report prints it.""" + return TITLE + + @property + def player(self) -> str: + """The program whose NSF driver plays the modules, as the report introduces it.""" + return PLAYER.format(executable=self.program.executable) + + def play( + self, + project: Project, + directory: Path, + name: str, + ) -> TargetPlayback: + """Exports a project to a module, has FamiTracker export it to an NSF, and reads every tick its driver plays. + + The module is built by the exporter the application's FamiTracker export runs and kept as + it is. FamiTracker exports a copy with a marker on every row (see :func:`marked_module`), + so its NSF says where each row starts. The NSF and every write its driver made are kept + beside the module. + + Args: + project: The project to export and play. + directory: Where the module, the NSF and the driver's writes are written. + name: The stem every file is written under. + + Returns: + TargetPlayback: The module, what FamiTracker played of it, and what the export left out. + + Raises: + FamiTrackerError: If the project exceeds what a module holds, FamiTracker writes no NSF, + or its NSF fails to play. + """ + built = _built_module(project, name) + document = directory / f"{name}{EXT_FILE_MODULE}" + document.write_bytes(module_to_ftm_bytes(built.document)) + nsf = directory / f"{name}{EXT_FILE_NSF}" + self._export_marked(built.document, nsf, name) + recorded = _played(nsf, built.document) + (directory / f"{name}{EXT_FILE_JSON}").write_text(recorded.model_dump_json(), encoding="utf-8") + return TargetPlayback( + document=document, + trace=recorded.song_trace(), + skipped_rows=built.skipped_rows, + truncation=built.truncation, + ) + + def _export_marked( + self, + module: FamiTrackerModule, + nsf: Path, + name: str, + ) -> None: + with TemporaryDirectory() as scratch: + marked = Path(scratch) / f"{name}{EXT_FILE_MODULE}" + marked.write_bytes(module_to_ftm_bytes(marked_module(module))) + export_nsf(self.program, marked, nsf, Path(scratch) / f"{name}{EXT_FILE_LOG}") + + +def _built_module(project: Project, name: str) -> BuiltDocument[FamiTrackerModule]: + try: + return build_module(project) + except ValueError as error: + raise FamiTrackerError(f"{name} doesn't fit a FamiTracker module: {error}") from error + + +def _played(nsf: Path, module: FamiTrackerModule) -> DriverTrace: + try: + return recorded_trace( + Console(nsf.read_bytes()), + frames=len(module.track.order), + rows=module.track.rows_per_pattern, + ) + except (ConsoleError, InvalidDataError) as error: + raise FamiTrackerError(f"The NSF FamiTracker exported to {nsf} failed to play: {error}") from error diff --git a/src/sampletones_tools/tracker_playback/targets/famitracker/trace.py b/src/sampletones_tools/tracker_playback/targets/famitracker/trace.py new file mode 100644 index 000000000..4fbe811e3 --- /dev/null +++ b/src/sampletones_tools/tracker_playback/targets/famitracker/trace.py @@ -0,0 +1,164 @@ +from typing import List, Protocol, Tuple + +from pydantic import BaseModel, ConfigDict, Field + +from sampletones_core.timing.bounds import MAX_TICKS_PER_ROW +from sampletones_player.specification.registers import ( + APU_FRAME_COUNTER, + DMC_DIRECT_LOAD, + FIRST_CHANNEL_REGISTER, + MAX_REGISTER_VALUE, +) +from sampletones_tools.player.trace.write import RegisterWrite +from sampletones_tools.tracker_playback.targets.famitracker.errors import FamiTrackerError +from sampletones_tools.tracker_playback.targets.famitracker.markers import row_marker +from sampletones_tools.tracker_playback.trace.decode import song_trace +from sampletones_tools.tracker_playback.trace.registers import ChipRegisters +from sampletones_tools.tracker_playback.trace.sound import SongTrace, TickPosition + + +class PlayCalls(Protocol): + """What runs an NSF one routine at a time and answers with the APU writes each routine made.""" + + def initialize(self) -> Tuple[RegisterWrite, ...]: + """Runs the init routine.""" + + def play(self) -> Tuple[RegisterWrite, ...]: + """Runs one play call.""" + + +class DriverWrite(BaseModel): + """One write FamiTracker's NSF driver makes to the APU. + + Attributes: + address: The register written. + value: The byte written. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + address: int = Field(..., ge=FIRST_CHANNEL_REGISTER, le=APU_FRAME_COUNTER) + value: int = Field(..., ge=0, le=MAX_REGISTER_VALUE) + + +class DriverTick(BaseModel): + """Every write FamiTracker's NSF driver made on one tick, and where in the song the tick falls. + + The first tick also carries the writes of any play call before the song's first row. + + Attributes: + frame: The order frame being played. + row: The row of that frame's patterns. + writes: The writes, in the order the driver made them. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + frame: int + row: int + writes: Tuple[DriverWrite, ...] + + +class DriverTrace(BaseModel): + """One pass through a song as FamiTracker's NSF driver plays it, tick by tick. + + Attributes: + initialization: The writes the init routine made. + ticks: Every tick of the pass. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + initialization: Tuple[DriverWrite, ...] + ticks: Tuple[DriverTick, ...] + + def song_trace(self) -> SongTrace: + """What every channel sounds on every tick, read out of the registers the driver wrote. + + Returns: + SongTrace: The pass. + """ + registers = ChipRegisters.power_up().written(_register_writes(self.initialization)) + per_tick: List[ChipRegisters] = [] + for tick in self.ticks: + registers = registers.written(_register_writes(tick.writes)) + per_tick.append(registers) + + return song_trace( + tuple(TickPosition(frame=tick.frame, row=tick.row) for tick in self.ticks), + per_tick, + ) + + +def recorded_trace( + calls: PlayCalls, + *, + frames: int, + rows: int, +) -> DriverTrace: + """Plays one pass through a song whose rows carry markers, and splits the play calls into its ticks. + + A play call writing a marker starts a row, and every call up to the next marker plays that row. + The pass ends at the marker after the song's last row, where the order comes back round. No + project holds a row longer than ``MAX_TICKS_PER_ROW`` ticks, so a row lasting longer is a driver + that stopped moving through the song. + + Args: + calls: The NSF's routines. + frames: The order frames the song plays. + rows: The rows each pattern holds. + + Returns: + DriverTrace: The pass. + + Raises: + FamiTrackerError: If a row's marker carries a level other than its place in the song, or a row + lasts longer than ``MAX_TICKS_PER_ROW`` calls. + """ + initialization = _driver_writes(calls.initialize()) + song_rows = frames * rows + row_index = -1 + row_ticks = 0 + pending: List[DriverWrite] = [] + ticks: List[DriverTick] = [] + while True: + writes = calls.play() + markers = [write.value for write in writes if write.address == DMC_DIRECT_LOAD] + if markers: + row_index += 1 + row_ticks = 0 + if row_index == song_rows: + break + + _check_marker(markers[-1], row_index) + + row_ticks += 1 + if row_ticks > MAX_TICKS_PER_ROW: + raise FamiTrackerError( + f"FamiTracker's NSF driver held row {row_index} for more than {MAX_TICKS_PER_ROW} ticks" + ) + + pending.extend(_driver_writes(writes)) + if row_index < 0: + continue + + ticks.append(DriverTick(frame=row_index // rows, row=row_index % rows, writes=tuple(pending))) + pending = [] + + return DriverTrace(initialization=initialization, ticks=tuple(ticks)) + + +def _check_marker(level: int, row_index: int) -> None: + expected = row_marker(row_index) + if level != expected: + raise FamiTrackerError( + f"FamiTracker's NSF driver marked row {row_index} with {level}, where that row carries {expected}" + ) + + +def _driver_writes(writes: Tuple[RegisterWrite, ...]) -> Tuple[DriverWrite, ...]: + return tuple(DriverWrite(address=write.address, value=write.value) for write in writes) + + +def _register_writes(writes: Tuple[DriverWrite, ...]) -> Tuple[RegisterWrite, ...]: + return tuple(RegisterWrite(write.address, write.value) for write in writes) diff --git a/src/sampletones_tools/tracker_playback/targets/protocol.py b/src/sampletones_tools/tracker_playback/targets/protocol.py index ca61f15f5..8be674ebe 100644 --- a/src/sampletones_tools/tracker_playback/targets/protocol.py +++ b/src/sampletones_tools/tracker_playback/targets/protocol.py @@ -1,7 +1,8 @@ from dataclasses import dataclass from pathlib import Path -from typing import Optional, Protocol +from typing import Optional, Protocol, Tuple +from sampletones_core.exporters.skipped import SkippedRow from sampletones_core.exporters.truncation import EnvelopeTruncation from sampletones_core.project.project import Project from sampletones_shared.exceptions import SampleToNESError @@ -19,13 +20,13 @@ class TargetPlayback: Attributes: document: The file the export wrote, which the tracker played. trace: What each channel sounded on every engine tick, read as the registers the chip takes. - skipped_rows: How many rows the export wrote as note cuts for lack of an instrument. + skipped_rows: The rows the export wrote other than the song plays them, each with why. truncation: The instruments the format's value limit shortened, or ``None`` where every one fit. """ document: Path trace: SongTrace - skipped_rows: int + skipped_rows: Tuple[SkippedRow, ...] truncation: Optional[EnvelopeTruncation] diff --git a/src/sampletones_tools/tracker_playback/trace/application.py b/src/sampletones_tools/tracker_playback/trace/application.py index e3ebcaa88..1b6d583d7 100644 --- a/src/sampletones_tools/tracker_playback/trace/application.py +++ b/src/sampletones_tools/tracker_playback/trace/application.py @@ -4,7 +4,7 @@ from sampletones_core.project.project import Project from sampletones_core.project.tuning import tuning_from_project from sampletones_core.timers.utils import get_timer_table -from sampletones_core.timing import Groove, SongTiming +from sampletones_core.timing import SONG_TICK_BOUNDS, SongTiming from sampletones_player.builder import streams_from_instructions from sampletones_player.registers.streams import ChannelStreams from sampletones_tools.player.trace.trace import channel_writes, setup_writes @@ -27,14 +27,14 @@ def application_trace(project: Project) -> SongTrace: Returns: SongTrace: One pass through the song, the order played once. """ - timing = SongTiming.from_project(project) + timing = SongTiming.from_project(project, bounds=SONG_TICK_BOUNDS) streams = streams_from_instructions( song_instructions(project), get_timer_table(tuning_from_project(project)), ) return song_trace( song_positions( - timing.groove(), + timing, project.song.order_length(), ), driver_registers( @@ -70,13 +70,13 @@ def driver_registers( def song_positions( - groove: Groove, + timing: SongTiming, frames: int, ) -> Tuple[TickPosition, ...]: - """Where each tick of a song falls, every frame lasting one groove. + """Where each tick of a song falls, every frame lasting the groove its place in the song gives it. Args: - groove: The ticks each row of a pattern lasts. + timing: How many ticks every row of the song lasts. frames: The order frames the song plays. Returns: @@ -84,7 +84,7 @@ def song_positions( """ positions: List[TickPosition] = [] for frame in range(frames): - for row, row_ticks in enumerate(groove.ticks): + for row, row_ticks in enumerate(timing.groove(frame).ticks): positions.extend(TickPosition(frame=frame, row=row) for _ in range(row_ticks)) return tuple(positions) diff --git a/tests/benchmarks/test_timing.py b/tests/benchmarks/test_timing.py new file mode 100644 index 000000000..aa1203dc2 --- /dev/null +++ b/tests/benchmarks/test_timing.py @@ -0,0 +1,55 @@ +from typing import Final + +import pytest + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.performance import song_instructions +from sampletones_core.project.project import Project +from sampletones_core.project.settings import ProjectSettings +from sampletones_core.timing import SONG_TICK_BOUNDS, SongTiming +from tests.suite.performance import make_pulse_reconstruction, place_instrument, project_with_sample +from tests.suite.timing import seconds + +ROWS_PER_PATTERN: Final[int] = 64 +FRAMES: Final[int] = 32 +UNEVEN_TEMPO: Final[int] = 251 +SAMPLE_FRAMES: Final[int] = 4 +TIMING_SHARE_LIMIT: Final[float] = 0.1 + + +@pytest.fixture(scope="module", name="project") +def project_fixture() -> Project: + """A song whose row rate never divides into whole ticks, so every frame plans its own bars.""" + project, sample = project_with_sample( + make_pulse_reconstruction(count=SAMPLE_FRAMES), + rows_per_pattern=ROWS_PER_PATTERN, + settings=ProjectSettings(tempo=UNEVEN_TEMPO, speed=6, nes_frequency=60), + ) + for row_index in range(0, ROWS_PER_PATTERN, 4): + place_instrument(project, channel_name=ChannelName.PULSE1, row_index=row_index, sample=sample) + + for _ in range(FRAMES - project.song.order_length()): + project.song.append_frame() + + return project + + +def _every_row(project: Project) -> int: + timing = SongTiming.from_project(project, bounds=SONG_TICK_BOUNDS) + return sum(timing.row_ticks(frame, row) for frame in range(FRAMES) for row in range(ROWS_PER_PATTERN)) + + +class TestTheTimingCostsNextToNothing: + """A player asks how long a row lasts as it reaches the row, so the answer has to cost a sliver of + what sounding the row costs. + + The reading is a ratio against the walk that plays the same song into instructions, since what a + machine walks a song in is its own. What the bound catches is a timing that replans its bars per + row or walks the song from its start to answer one row. + """ + + def test_every_rows_length_costs_a_sliver_of_the_walk(self, project: Project) -> None: + lookups = seconds(lambda: _every_row(project)) + walk = seconds(lambda: song_instructions(project)) + + assert lookups < walk * TIMING_SHARE_LIMIT, f"lookups {lookups:.4f}s, walk {walk:.4f}s" diff --git a/tests/integration/bitphase/test_btp_pipeline.py b/tests/integration/bitphase/test_btp_pipeline.py index f0b485829..ac99d2a3b 100644 --- a/tests/integration/bitphase/test_btp_pipeline.py +++ b/tests/integration/bitphase/test_btp_pipeline.py @@ -4,7 +4,7 @@ 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.builder import BITPHASE_TICK_BOUNDS, project_to_bitphase from sampletones_core.formats.bitphase.specification.channels import ( CHANNEL_COUNT, CHANNEL_LABELS, @@ -21,8 +21,6 @@ ChipVariant, ) from sampletones_core.formats.bitphase.specification.effects import ( - NO_EFFECT_PARAMETER, - SPEED_EFFECT_DELAY, EffectId, ) from sampletones_core.formats.bitphase.specification.instruments import ( @@ -49,16 +47,15 @@ NoteName, ) from sampletones_core.project.project import Project -from sampletones_core.timing import Meter, RowRate, calculate_groove +from sampletones_core.timing import SongTiming from sampletones_tools.samples.bitphase import GROOVE_TEMPO, at_tempo from tests.suite.bitphase import ( BITPHASE_NO_EFFECTS, - LoadedEffect, LoadedNote, LoadedProject, LoadedRow, - LoadedTable, parse_btp, + row_speeds, ) EXPECTED_INSTRUMENT_COUNT: Final[int] = 5 @@ -220,69 +217,46 @@ def test_every_volume_column_stays_within_the_channel_range(self, document: Load class TestTheGrooveReachesTheFile: - """A tempo the speed column cannot state travels as a table of per-row tick counts and a - trigger that names it, so the file has to hold the groove the calculator produced and - re-trigger it wherever the order takes playback. + """A tempo the speed column cannot state travels as speed effects on the silent channel, set + wherever a row lasts differently from the row before it, so every row the engine loads plays the + ticks the song's timing gives it. """ - @pytest.fixture(name="groove_table") - def groove_table_fixture(self, groove_document: LoadedProject) -> LoadedTable: - return groove_document.tables[-1] - - def test_the_groove_takes_the_table_above_the_slices( - self, - groove_document: LoadedProject, - groove_table: LoadedTable, - ) -> None: - assert groove_table.id == len(groove_document.instruments) - - def test_the_table_holds_one_entry_per_pattern_row( + def test_every_row_plays_the_ticks_the_songs_timing_gives_it( self, + integration_project: Project, groove_document: LoadedProject, - groove_table: LoadedTable, ) -> None: - lengths = {pattern.length for pattern in groove_document.songs[0].patterns} - assert lengths == {len(groove_table.rows)} + project = at_tempo(integration_project, GROOVE_TEMPO) + timing = SongTiming.from_project(project, bounds=BITPHASE_TICK_BOUNDS) + expected = [list(timing.groove(frame).ticks) for frame in range(project.song.order_length())] + + assert row_speeds(groove_document) == expected + + def test_every_speed_is_one_the_engine_reads(self, groove_document: LoadedProject) -> None: + speeds = [ + effect.parameter + for pattern in groove_document.songs[0].patterns + for row in pattern.channels[int(ChannelIndex.DPCM)].rows + for effect in row.effects + if effect is not None and effect.effect == int(EffectId.SPEED) + ] - def test_every_entry_is_a_speed_the_engine_reads(self, groove_table: LoadedTable) -> None: - assert all(MIN_INITIAL_SPEED <= ticks <= MAX_INITIAL_SPEED for ticks in groove_table.rows) + assert speeds + assert all(MIN_INITIAL_SPEED <= speed <= MAX_INITIAL_SPEED for speed in speeds) - def test_the_table_holds_the_groove_the_project_plays( - self, - integration_project: Project, - groove_table: LoadedTable, - ) -> None: - project = at_tempo(integration_project, GROOVE_TEMPO) - groove = calculate_groove( - RowRate.from_settings(project.settings), - Meter.from_settings(project.settings, rows=project.song.rows_per_pattern), - minimum_ticks=MIN_INITIAL_SPEED, - maximum_ticks=MAX_INITIAL_SPEED, - ) - assert groove_table.rows == list(groove.ticks) + def test_the_document_holds_one_table_per_slice(self, groove_document: LoadedProject) -> None: + assert len(groove_document.tables) == len(groove_document.instruments) def test_the_song_starts_on_the_ticks_its_first_row_lasts( self, + integration_project: Project, groove_document: LoadedProject, - groove_table: LoadedTable, ) -> None: - assert groove_document.songs[0].initial_speed == groove_table.rows[0] + project = at_tempo(integration_project, GROOVE_TEMPO) + timing = SongTiming.from_project(project, bounds=BITPHASE_TICK_BOUNDS) - def test_every_pattern_triggers_the_groove_on_its_first_row( - self, - groove_document: LoadedProject, - groove_table: LoadedTable, - ) -> None: - trigger = LoadedEffect( - effect=int(EffectId.SPEED), - delay=SPEED_EFFECT_DELAY, - parameter=NO_EFFECT_PARAMETER, - table_index=groove_table.id, - ) - triggers = [ - pattern.channels[int(ChannelIndex.DPCM)].rows[0].effects for pattern in groove_document.songs[0].patterns - ] - assert triggers == [[trigger]] * len(triggers) + assert groove_document.songs[0].initial_speed == timing.row_ticks(0, 0) def test_a_tempo_the_speed_column_states_leaves_every_effect_column_empty( self, diff --git a/tests/integration/nsf/console/instructions.py b/tests/integration/nsf/console/instructions.py index 01921a96b..f368450b8 100644 --- a/tests/integration/nsf/console/instructions.py +++ b/tests/integration/nsf/console/instructions.py @@ -27,7 +27,7 @@ ) from sampletones_shared.music import Tuning from sampletones_tools.player.trace.trace import RegisterTrace -from tests.integration.nsf.console.machine import register_file +from tests.integration.nsf.console.registers import register_file TRIANGLE_SOUNDING: Final[int] = TRIANGLE_COUNTER_CONTROL | TRIANGLE_SOUNDING_RELOAD diff --git a/tests/integration/nsf/console/machine.py b/tests/integration/nsf/console/machine.py deleted file mode 100644 index 87edc8778..000000000 --- a/tests/integration/nsf/console/machine.py +++ /dev/null @@ -1,132 +0,0 @@ -from typing import Dict, Final, List, Tuple - -from py65.devices.mpu6502 import MPU -from py65.memory import ObservableMemory - -from sampletones_player.driver.addresses import DriverAddresses -from sampletones_player.specification.nsf import HEADER_SIZE -from sampletones_player.specification.registers import ( - APU_FRAME_COUNTER, - FIRST_CHANNEL_REGISTER, -) -from sampletones_tools.player.trace.trace import RegisterTrace -from sampletones_tools.player.trace.write import RegisterWrite - -RETURN_SENTINEL: Final[int] = 0xFFF0 -STACK_PAGE: Final[int] = 0x0100 -STACK_TOP: Final[int] = 0xFF -FIRST_SONG_INDEX: Final[int] = 0x00 -NTSC_MACHINE: Final[int] = 0x00 -STEP_BUDGET: Final[int] = 1_000_000 - - -class Console: - """A 6502 running an exported file, watching every APU register the driver writes. - - An NSF player loads the image behind the header, calls the init routine once with the song - number in the accumulator and the machine in X, and calls the play routine at the rate the - header asks for. This runs the same sequence over py65's CPU with the APU's address range - subscribed, so each routine answers with the writes it made. Capturing writes rather than - sound is what lets a run stand against `RegisterTrace.from_song` value for value. - """ - - def __init__(self, data: bytes, addresses: DriverAddresses) -> None: - """Loads an exported file the way a player loads it. - - Args: - data: The whole `.nsf` file, header included. - addresses: Where the image loads and which routines it answers at. - """ - self._addresses = addresses - self._writes: List[RegisterWrite] = [] - self._memory = ObservableMemory() - self._memory.write(addresses.load, list(data[HEADER_SIZE:])) - self._memory.subscribe_to_write( - range(FIRST_CHANNEL_REGISTER, APU_FRAME_COUNTER + 1), - self._observe, - ) - self._processor = MPU(memory=self._memory) - - def _observe(self, address: int, value: int) -> None: - self._writes.append(RegisterWrite(address, value)) - - def _seed_stack(self) -> None: - self._memory[STACK_PAGE + STACK_TOP] = (RETURN_SENTINEL - 1) >> 8 - self._memory[STACK_PAGE + STACK_TOP - 1] = (RETURN_SENTINEL - 1) & 0xFF - self._processor.sp = STACK_TOP - 2 - - def _call(self, address: int, accumulator: int) -> Tuple[RegisterWrite, ...]: - self._writes = [] - self._processor.a = accumulator - self._processor.x = NTSC_MACHINE - self._seed_stack() - self._processor.pc = address - - for _ in range(STEP_BUDGET): - if self._processor.pc == RETURN_SENTINEL: - return tuple(self._writes) - - self._processor.step() - - raise RuntimeError(f"the routine at {address:#06x} ran for {STEP_BUDGET} instructions without returning") - - def initialize(self) -> Tuple[RegisterWrite, ...]: - """Runs the init routine, which readies the APU and sounds the song's first tick. - - Returns: - Tuple[RegisterWrite, ...]: Every register the routine wrote, in order. - """ - return self._call(self._addresses.init, FIRST_SONG_INDEX) - - def play(self) -> Tuple[RegisterWrite, ...]: - """Runs one play call, the way the console calls it each frame. - - Returns: - Tuple[RegisterWrite, ...]: Every register the call wrote, empty where the streams - hold their tick through it. - """ - return self._call(self._addresses.play, FIRST_SONG_INDEX) - - def trace(self, play_calls: int) -> RegisterTrace: - """Runs a whole session: initialization followed by ``play_calls`` play calls. - - Args: - play_calls: How many play calls the run covers. - - Returns: - RegisterTrace: The writes the driver made, grouped the way the model states them. - """ - initialization = self.initialize() - return RegisterTrace( - initialization=initialization, - play_calls=tuple(self.play() for _ in range(play_calls)), - ) - - -def register_file(trace: RegisterTrace) -> List[Dict[int, int]]: - """The APU as the driver leaves it after initialization and after every call that sounds. - - A tick reaches the hardware as the values standing in the registers once its writes land, and - the three registers written only on change keep the value an earlier tick left there. Reading - the whole file back after each sounding call is therefore what recovers a tick's full state - from a trace that states only what changed. - - Args: - trace: The writes a run of the driver made. - - Returns: - List[Dict[int, int]]: One register file per tick the run sounded, in order. - """ - registers: Dict[int, int] = {} - ticks: List[Dict[int, int]] = [] - - for writes in (trace.initialization, *trace.play_calls): - if not writes: - continue - - for write in writes: - registers[write.address] = write.value - - ticks.append(dict(registers)) - - return ticks diff --git a/tests/integration/nsf/console/registers.py b/tests/integration/nsf/console/registers.py new file mode 100644 index 000000000..5b52cca53 --- /dev/null +++ b/tests/integration/nsf/console/registers.py @@ -0,0 +1,32 @@ +from typing import Dict, List + +from sampletones_tools.player.trace.trace import RegisterTrace + + +def register_file(trace: RegisterTrace) -> List[Dict[int, int]]: + """The APU as the driver leaves it after initialization and after every call that sounds. + + A tick reaches the hardware as the values standing in the registers once its writes land, and + the three registers written only on change keep the value an earlier tick left there. Reading + the whole file back after each sounding call is therefore what recovers a tick's full state + from a trace that states only what changed. + + Args: + trace: The writes a run of the driver made. + + Returns: + List[Dict[int, int]]: One register file per tick the run sounded, in order. + """ + registers: Dict[int, int] = {} + ticks: List[Dict[int, int]] = [] + + for writes in (trace.initialization, *trace.play_calls): + if not writes: + continue + + for write in writes: + registers[write.address] = write.value + + ticks.append(dict(registers)) + + return ticks diff --git a/tests/integration/nsf/console/session.py b/tests/integration/nsf/console/session.py index 7f64b337c..fa34bd862 100644 --- a/tests/integration/nsf/console/session.py +++ b/tests/integration/nsf/console/session.py @@ -4,8 +4,8 @@ from sampletones_player.nsf.file import nsf_to_bytes from sampletones_player.nsf.information import NSFInformation from sampletones_player.song import Song +from sampletones_tools.console.machine import Console from sampletones_tools.player.trace.trace import RegisterTrace -from tests.integration.nsf.console.machine import Console TRAILING_CALLS: Final[int] = 2 @@ -80,9 +80,7 @@ def captured_run(data: bytes, play_calls: int) -> RegisterTrace: Returns: RegisterTrace: The writes of the initialization and of every play call in the run. """ - image = DriverImage.load() - console = Console(data, image.addresses) - return console.trace(play_calls) + return Console(data).trace(play_calls) def captured_trace(song: Song, information: NSFInformation) -> RegisterTrace: diff --git a/tests/integration/nsf/test_backend.py b/tests/integration/nsf/test_backend.py index b75bdcff9..d2eba1872 100644 --- a/tests/integration/nsf/test_backend.py +++ b/tests/integration/nsf/test_backend.py @@ -18,7 +18,7 @@ from sampletones_core.project.project import Project from sampletones_core.project.tuning import tuning_from_project from sampletones_core.project.voices.sample import Sample -from sampletones_core.timing import SongTiming +from sampletones_core.timing import SONG_TICK_BOUNDS, SongTiming from sampletones_player.builder import ( SONG_START, instructions_from_instruments, @@ -340,7 +340,7 @@ def test_a_song_repeating_from_a_frame_comes_round_to_that_frame( tmp_path: Path, ) -> None: """The calls past the arrangement's end sound the frame the program returns to, onward.""" - loop_tick = SongTiming.from_project(integration_project).frame_tick(LOOP_FRAME) + loop_tick = SongTiming.from_project(integration_project, bounds=SONG_TICK_BOUNDS).frame_tick(LOOP_FRAME) program = NSFProgram.for_project(integration_project).model_copy(update={"loop_tick": loop_tick}) data = chosen_file(backend, integration_project, program, tmp_path).read_bytes() ticks = project_song.ticks diff --git a/tests/integration/nsf/test_driver_bend.py b/tests/integration/nsf/test_driver_bend.py index ecbebf015..1ffe63892 100644 --- a/tests/integration/nsf/test_driver_bend.py +++ b/tests/integration/nsf/test_driver_bend.py @@ -16,7 +16,7 @@ from sampletones_player.specification.registers import PULSE1_TIMER_HIGH, TIMER_HIGH_SHIFT from sampletones_tools.player.trace.trace import RegisterTrace from tests.integration.nsf.console.instructions import channel_values, timer_value -from tests.integration.nsf.console.machine import register_file +from tests.integration.nsf.console.registers import register_file from tests.integration.nsf.console.session import ( captured_trace, captured_trace_over, diff --git a/tests/integration/nsf/test_song_export.py b/tests/integration/nsf/test_song_export.py index d6cf2f89a..699203ed1 100644 --- a/tests/integration/nsf/test_song_export.py +++ b/tests/integration/nsf/test_song_export.py @@ -4,7 +4,7 @@ from sampletones_core.constants.enums import ALL_CHANNELS from sampletones_core.project.project import Project -from sampletones_core.timing import SongTiming +from sampletones_core.timing import SONG_TICK_BOUNDS, SongTiming from sampletones_player.builder import song_from_project from sampletones_player.compression.scheme import CompressionScheme from sampletones_player.driver.image import DriverImage @@ -36,13 +36,13 @@ def project_song(integration_project: Project) -> Song: class TestTheProjectReachesTheConsole: """A whole arrangement flattened into the streams the driver already plays.""" - def test_the_song_lasts_the_ticks_the_projects_groove_gives_its_order( + def test_the_song_lasts_the_ticks_the_projects_timing_gives_its_order( self, integration_project: Project, project_song: Song, ) -> None: - groove = SongTiming.from_project(integration_project).groove() - assert project_song.ticks == integration_project.song.order_length() * groove.total_ticks + timing = SongTiming.from_project(integration_project, bounds=SONG_TICK_BOUNDS) + assert project_song.ticks == timing.frame_tick(integration_project.song.order_length()) def test_every_channel_the_order_plays_sounds(self, project_song: Song) -> None: """The fixture's arrangement fills all four channels, so none of them rests throughout.""" diff --git a/tests/integration/nsf/test_song_timing.py b/tests/integration/nsf/test_song_timing.py new file mode 100644 index 000000000..0ea7b3023 --- /dev/null +++ b/tests/integration/nsf/test_song_timing.py @@ -0,0 +1,44 @@ +from fractions import Fraction +from typing import Final + +import pytest + +from sampletones_core.timing import SONG_TICK_BOUNDS, Meter, RowRate, SongTiming +from sampletones_player.clock.schedule import PlaySchedule +from sampletones_player.specification.clock import NTSC_FRAME_RATE +from tests.suite.groove import bar_rows + +COMMON_TIME: Final[Meter] = Meter(rows=16, first_highlight=4, second_highlight=16) +FRAMES: Final[int] = 100 +HALF: Final[Fraction] = Fraction(1, 2) +CALL_SECONDS: Final[Fraction] = 1 / Fraction(NTSC_FRAME_RATE) + + +class TestABarLineReachesTheConsoleOnTime: + """Rows are planned in engine ticks, and the console's call re-clocks the ticks onto its own rate. + + Each layer carries its own remainder, so a bar line the console plays lands within half a tick + of its exact moment plus one call, at any engine rate and however long the song runs. The drift + the driver's rounded step builds over the run is counted on top. + """ + + @pytest.mark.parametrize("nes_frequency", (15, 41, 60, 300)) + @pytest.mark.parametrize("tempo", (97, 125, 210, 251)) + def test_every_bar_line_lands_within_half_a_tick_and_one_call(self, nes_frequency: int, tempo: int) -> None: + timing = SongTiming( + rate=RowRate.from_parameters(tempo=tempo, speed=6, nes_frequency=nes_frequency), + meter=COMMON_TIME, + bounds=SONG_TICK_BOUNDS, + ) + schedule = PlaySchedule.from_parameters(nes_frequency) + calls = 0 + tick_seconds = 1 / Fraction(nes_frequency) + drift = schedule.maximum_drift(int(timing.frame_tick(FRAMES) / schedule.ticks_per_play_call) + 1) + allowed = (HALF + drift) * tick_seconds + CALL_SECONDS + for row in bar_rows(COMMON_TIME, FRAMES): + tick = timing.tick_at(*divmod(row, COMMON_TIME.rows)) + while schedule.ticks_at(calls + 1) < tick: + calls += 1 + + exact = row * timing.exact_row_ticks * tick_seconds + assert abs(calls * CALL_SECONDS - exact) <= allowed diff --git a/tests/integration/sampletones_application/services/test_export.py b/tests/integration/sampletones_application/services/test_export.py index 32f941dc2..2dc604099 100644 --- a/tests/integration/sampletones_application/services/test_export.py +++ b/tests/integration/sampletones_application/services/test_export.py @@ -19,7 +19,7 @@ ) from sampletones_core.exports.stage import ExportStage from sampletones_core.project.project import Project -from sampletones_core.timing import SongTiming +from sampletones_core.timing import SONG_TICK_BOUNDS, SongTiming from sampletones_player.export.backend import NSFBackend from sampletones_player.specification.nsf import NSF_MAGIC, PROGRAM_SIZE from sampletones_shared.music import Tuning @@ -297,6 +297,6 @@ def test_the_walk_reads_as_a_fraction_of_the_song(self, tmp_path, console_backen for result in results if isinstance(result, ServiceProgress) and result.current_item == ExportStage.WALKING ] - groove = SongTiming.from_project(project).groove() + timing = SongTiming.from_project(project, bounds=SONG_TICK_BOUNDS) assert walked - assert walked[-1].total == project.song.order_length() * groove.total_ticks + assert walked[-1].total == timing.frame_tick(project.song.order_length()) diff --git a/tests/suite/bitphase.py b/tests/suite/bitphase.py index 940bbc2a2..2312ded3b 100644 --- a/tests/suite/bitphase.py +++ b/tests/suite/bitphase.py @@ -520,23 +520,38 @@ def _next_step(position: int, table: LoadedTable) -> int: return table.loop if 0 < table.loop < len(table.rows) else BITPHASE_FIRST_STEP -def _row_speeds(document: LoadedProject, pattern: LoadedPattern) -> List[int]: - """The ticks each row of a pattern lasts: the entries of the table a speed effect names, or the initial speed. +def row_speeds(document: LoadedProject) -> List[List[int]]: + """The ticks each row of every pattern lasts, the order played once through as the engine reads it. - The groove reaches the engine as a table a speed effect reads one entry per row from, triggered on - every pattern's first row, which is the only way the export states a speed. + The song starts at its initial speed, and a speed effect sets the speed from its row on, the last + one on a row winning across its channels, as ``readLastSpeedCommandOnRow`` of + ``playback-speed.ts`` reads it. Every speed the export states rides a speed effect's own + parameter. + + Args: + document: The document as Bitphase loads it. + + Returns: + List[List[int]]: One list per pattern of the order, one speed per row. """ - triggers = [ - effect - for channel in pattern.channels - for effect in channel.rows[0].effects - if effect is not None and effect.effect == BITPHASE_SPEED_EFFECT and effect.table_index is not None - ] - if not triggers: - return [document.songs[0].initial_speed] * pattern.length + song = document.songs[0] + patterns = {pattern.id: pattern for pattern in song.patterns} + speed = song.initial_speed + speeds: List[List[int]] = [] + for pattern_id in document.pattern_order: + pattern = patterns[pattern_id] + pattern_speeds: List[int] = [] + for row_index in range(pattern.length): + for channel in pattern.channels: + for effect in channel.rows[row_index].effects: + if effect is not None and effect.effect == BITPHASE_SPEED_EFFECT and effect.parameter > 0: + speed = effect.parameter - table = next(table for table in document.tables if table.id == triggers[-1].table_index) - return [table.rows[row % len(table.rows)] for row in range(pattern.length)] + pattern_speeds.append(speed) + + speeds.append(pattern_speeds) + + return speeds @dataclass @@ -598,9 +613,9 @@ def played_notes(document: LoadedProject, channel_index: int) -> List[Optional[i tables = {table.id: table for table in document.tables} replay = _ChannelReplay() notes: List[Optional[int]] = [] - for pattern_id in document.pattern_order: + for pattern_id, speeds in zip(document.pattern_order, row_speeds(document)): pattern = patterns[pattern_id] - for row, speed in zip(pattern.channels[channel_index].rows, _row_speeds(document, pattern)): + for row, speed in zip(pattern.channels[channel_index].rows, speeds): replay.read(row, tables) notes.extend(replay.tick(song.tuning_table) for _ in range(speed)) diff --git a/tests/suite/famitracker.py b/tests/suite/famitracker.py index 1f3f21394..4fc24a289 100644 --- a/tests/suite/famitracker.py +++ b/tests/suite/famitracker.py @@ -1,6 +1,6 @@ import struct from dataclasses import dataclass, field -from typing import Dict, Final, List, Optional, Sequence, Tuple +from typing import Dict, Final, List, Optional, Tuple from sampletones_core.formats.famitracker.specification.blocks import BLOCK_NAME_LENGTH from sampletones_core.formats.famitracker.specification.file import ( @@ -14,6 +14,7 @@ from sampletones_core.formats.famitracker.specification.sequences import ( SEQUENCE_COUNT_2A03, ) +from sampletones_core.timing.song import SongTiming FAMITRACKER_OPENING_VOLUME: Final[int] = 15 FAMITRACKER_EMPTY_VOLUME: Final[int] = 0x10 @@ -140,18 +141,17 @@ def _resolved(self, note: int) -> int: def played_notes( module: "ParsedModule", channel_id: int, - row_ticks: Sequence[int], + timing: SongTiming, ) -> List[Optional[int]]: """The note one channel sounds on each tick, the order played once through as the tracker reads it. - The ticks each row lasts are given, so the replay follows the rows of a song whose timing it - compares against. The note counts semitones from C-0 on the tonal channels and is the period on - noise. + The song's timing is given, so the replay follows the rows of a song whose timing it compares + against. The note counts semitones from C-0 on the tonal channels and is the period on noise. Args: module: The module as it was written. channel_id: The channel whose notes are read. - row_ticks: The ticks each row of a pattern lasts. + timing: How many ticks every row of the song lasts. Returns: List[Optional[int]]: One note per tick, and ``None`` where the channel holds no note. @@ -170,14 +170,14 @@ def played_notes( } replay = _ChannelReplay(noise=channel_id == FAMITRACKER_NOISE_CHANNEL) notes: List[Optional[int]] = [] - for frame in module.frames.order: + for frame_index, frame in enumerate(module.frames.order): rows = patterns.get(frame[channel_id], {}) for row_number in range(module.frames.pattern_length): row = rows.get(row_number) if row is not None: replay.read(row, arpeggios) - notes.extend(replay.tick() for _ in range(row_ticks[row_number])) + notes.extend(replay.tick() for _ in range(timing.row_ticks(frame_index, row_number))) return notes diff --git a/tests/suite/groove.py b/tests/suite/groove.py new file mode 100644 index 000000000..a0860392a --- /dev/null +++ b/tests/suite/groove.py @@ -0,0 +1,66 @@ +from fractions import Fraction +from itertools import accumulate +from math import ceil, floor +from typing import List, Sequence, Tuple + +from sampletones_core.timing.meter import Meter + + +def bar_rows(meter: Meter, frames: int) -> Tuple[int, ...]: + """The row every bar of ``frames`` patterns starts on, counted from the song's first row. + + The meter restarts at every pattern, so each pattern opens a bar. + """ + starts: List[int] = [] + for frame in range(frames): + row = frame * meter.rows + for beats in meter.spans: + starts.append(row) + row += sum(beats) + + return tuple(starts) + + +def beat_spans(meter: Meter, frames: int) -> Tuple[Tuple[int, int], ...]: + """The first row and the row count of every beat of ``frames`` patterns.""" + spans: List[Tuple[int, int]] = [] + for frame in range(frames): + row = frame * meter.rows + for beats in meter.spans: + for beat in beats: + spans.append((row, beat)) + row += beat + + return tuple(spans) + + +def bar_line_drift( + ticks: Sequence[int], + starts: Sequence[int], + row_ticks: Fraction, +) -> Fraction: + """The furthest a bar starts from its exact start, in ticks, over a run of rows. + + A row's exact start is its index times ``row_ticks``, counted from the run's first row, and its + played start is the ticks every row before it lasted. + + Args: + ticks: The ticks each row of the run lasts. + starts: The row every bar starts on. + row_ticks: The exact ticks one row lasts. + + Returns: + Fraction: The largest gap between a bar's played start and its exact one. + """ + played = (0, *accumulate(ticks)) + return max(abs(played[row] - row * row_ticks) for row in starts) + + +def is_proportional(ticks: Sequence[int], row_ticks: Fraction) -> bool: + """Whether every row lasts the floor or the ceiling of its exact length.""" + return all(floor(row_ticks) <= row <= ceil(row_ticks) for row in ticks) + + +def surplus_rows(beat: Sequence[int], row_ticks: Fraction) -> Tuple[int, ...]: + """The rows of a beat that last the ceiling of a fractional row length, by their place in the beat.""" + return tuple(index for index, row in enumerate(beat) if row > floor(row_ticks)) diff --git a/tests/suite/playback.py b/tests/suite/playback.py index 36baa5382..88127353f 100644 --- a/tests/suite/playback.py +++ b/tests/suite/playback.py @@ -1,6 +1,7 @@ from pathlib import Path from typing import Final, List, Tuple +from sampletones_core.exporters.skipped import NO_SKIPPED_ROWS from sampletones_core.project.project import Project from sampletones_tools.tracker_playback.targets.protocol import TargetPlayback from sampletones_tools.tracker_playback.trace.application import application_trace @@ -35,6 +36,6 @@ def play( return TargetPlayback( document=document, trace=application_trace(project), - skipped_rows=0, + skipped_rows=NO_SKIPPED_ROWS, truncation=None, ) diff --git a/tests/unit/sampletones_application/logic/export/nsf/test_logic.py b/tests/unit/sampletones_application/logic/export/nsf/test_logic.py index 2ed548e41..ca756e704 100644 --- a/tests/unit/sampletones_application/logic/export/nsf/test_logic.py +++ b/tests/unit/sampletones_application/logic/export/nsf/test_logic.py @@ -13,7 +13,7 @@ from sampletones_application.view_model.shared.nsf.view import NSFExportViewModel from sampletones_core.constants.enums import ChannelName from sampletones_core.exports.request import SampleExport -from sampletones_core.timing import SongTiming +from sampletones_core.timing import SONG_TICK_BOUNDS, SongTiming from sampletones_player.builder import SONG_START from sampletones_player.compression.scheme import CompressionScheme, offered_schemes from sampletones_player.export.program import NSFProgram @@ -123,7 +123,9 @@ def test_the_length_is_the_whole_orders(self, export: NSFExportFixture) -> None: export.logic.open_project() project = export.controller.project - assert export.view.ticks == SongTiming.from_project(project).frame_tick(project.song.order_length()) + assert export.view.ticks == SongTiming.from_project(project, bounds=SONG_TICK_BOUNDS).frame_tick( + project.song.order_length() + ) assert export.view.nes_frequency == project.settings.nes_frequency def test_the_setup_occupies_the_application_from_the_dialog_opening(self, export: NSFExportFixture) -> None: @@ -216,7 +218,9 @@ def test_the_program_carries_the_choices(self, export: NSFExportFixture) -> None program = export.backend.program assert program.information == choices.information assert program.channels == frozenset(ChannelName.items()) - {ChannelName.NOISE} - assert program.loop_tick == SongTiming.from_project(export.controller.project).frame_tick(LOOP_FRAME) + assert program.loop_tick == SongTiming.from_project( + export.controller.project, bounds=SONG_TICK_BOUNDS + ).frame_tick(LOOP_FRAME) assert program.scheme == CompressionScheme.RUNS def test_handing_over_closes_the_setup(self, export: NSFExportFixture) -> None: diff --git a/tests/unit/sampletones_application/logic/sequencer/playback/test_song_length.py b/tests/unit/sampletones_application/logic/sequencer/playback/test_song_length.py index 2cc9aaff0..49f11c7ec 100644 --- a/tests/unit/sampletones_application/logic/sequencer/playback/test_song_length.py +++ b/tests/unit/sampletones_application/logic/sequencer/playback/test_song_length.py @@ -2,7 +2,7 @@ from sampletones_application.logic.project.controller import ProjectController from sampletones_application.logic.sequencer.playback.synthesizer import SongLength -from sampletones_core.timing import SongTiming +from sampletones_core.timing import SONG_TICK_BOUNDS, SongTiming FRACTIONAL_RATE: Final[int] = 22050 EXACT_RATE: Final[int] = 44100 @@ -13,26 +13,27 @@ def measure(controller: ProjectController, sample_rate: int) -> SongLength: class TestTheOrderStatesTheTicks: - def test_the_song_lasts_its_groove_once_for_each_order_position( + def test_the_song_lasts_until_the_frame_past_its_last_would_start( self, controller: ProjectController, ) -> None: - groove = SongTiming.from_project(controller.project).groove() + timing = SongTiming.from_project(controller.project, bounds=SONG_TICK_BOUNDS) length = measure(controller, EXACT_RATE) - assert length.ticks == controller.project.song.order_length() * groove.total_ticks + assert length.ticks == timing.frame_tick(controller.project.song.order_length()) - def test_appending_a_frame_lengthens_the_song_by_a_pattern( + def test_appending_a_frame_lengthens_the_song_by_the_ticks_that_frame_plays( self, controller: ProjectController, ) -> None: before = measure(controller, EXACT_RATE) - groove = SongTiming.from_project(controller.project).groove() + appended = controller.project.song.order_length() controller.append_frame() - assert measure(controller, EXACT_RATE).ticks == before.ticks + groove.total_ticks + groove = SongTiming.from_project(controller.project, bounds=SONG_TICK_BOUNDS).groove(appended) + assert measure(controller, EXACT_RATE).ticks == before.ticks + sum(groove.ticks) class TestTheRateStatesTheSamples: diff --git a/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py b/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py index e5c4c3076..44a326dfb 100644 --- a/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py +++ b/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py @@ -13,13 +13,7 @@ from sampletones_core.features import CHANNEL_FEATURE_DEFAULTS from sampletones_core.performance import ChannelPerformance from sampletones_core.reconstructions import Reconstruction -from sampletones_core.timing import ( - MAX_TICKS_PER_ROW, - MIN_TICKS_PER_ROW, - Meter, - RowRate, - calculate_groove, -) +from sampletones_core.timing import SONG_TICK_BOUNDS, SongTiming from tests.suite.performance import ( make_noise_reconstruction, make_pulse_reconstruction, @@ -40,6 +34,7 @@ SAMPLE_RATE: Final[int] = DEFAULT_SAMPLE_RATE SUSTAINED_FRAMES: Final[int] = 64 QUIET_VOLUME: Final[int] = 3 +FIRST_FRAME: Final[int] = 0 class MaskProvider: @@ -94,15 +89,9 @@ def _render(context: SynthesizerContext) -> np.ndarray: return audio -def _groove_ticks(controller: ProjectController) -> Tuple[int, ...]: - """The ticks each row of a pattern owes the project's timing, from the timing package itself.""" - settings = controller.project.settings - return calculate_groove( - RowRate.from_settings(settings), - Meter.from_settings(settings, rows=controller.project.song.rows_per_pattern), - minimum_ticks=MIN_TICKS_PER_ROW, - maximum_ticks=MAX_TICKS_PER_ROW, - ).ticks +def _groove_ticks(controller: ProjectController, frame: int) -> Tuple[int, ...]: + """The ticks each row of a frame owes the project's timing, from the timing package itself.""" + return SongTiming.from_project(controller.project, bounds=SONG_TICK_BOUNDS).groove(frame).ticks def _row_ticks( @@ -709,7 +698,7 @@ def render_and_assert_chunk_length(context: SynthesizerContext) -> None: settings = controller.project.settings frame_length = settings.sample_rate // settings.nes_frequency audio = _render(context) - assert len(audio) == frame_length * _groove_ticks(controller)[0] + assert len(audio) == frame_length * _groove_ticks(controller, FIRST_FRAME)[0] BaseTestScenario( label="chunk length matches the groove's first row", @@ -739,15 +728,15 @@ def test_a_pattern_plays_the_groove_the_meter_yields( rendered = _row_ticks(synthesizer, controller.project.song.rows_per_pattern) assert rendered == (5, 4, 5, 4, 5, 4, 4, 4, 5, 4, 4, 4, 5, 4, 4, 4) - assert rendered == _groove_ticks(controller) + assert rendered == _groove_ticks(controller, FIRST_FRAME) - def test_the_groove_restarts_with_the_pattern( + def test_the_song_start_plays_its_first_rows_again( self, controller: ProjectController, synthesizer: RowSynthesizer, ) -> None: - """Every row reads the groove entry its position in the pattern names, so returning to - row 0 plays row 0's duration again — the phase an exported module also restarts on. + """Every row reads its duration from its place in the song, so returning to the first row + plays that row's duration again, as an exported song does when it comes round. """ controller.set_rows_per_pattern(16) controller.set_tempo(210) @@ -759,6 +748,23 @@ def test_the_groove_restarts_with_the_pattern( assert opening == (5, 4, 5) assert again == (opening[0],) + def test_the_next_frame_plays_the_groove_its_place_gives_it( + self, + controller: ProjectController, + synthesizer: RowSynthesizer, + ) -> None: + """A 16-row pattern lasts 68 4/7 ticks at tempo 210, so the second frame plays one tick fewer.""" + controller.set_rows_per_pattern(16) + controller.set_tempo(210) + controller.append_frame() + rows = controller.project.song.rows_per_pattern + + first = _row_ticks(synthesizer, rows) + second = _row_ticks(synthesizer, rows) + + assert (first, second) == (_groove_ticks(controller, FIRST_FRAME), _groove_ticks(controller, FIRST_FRAME + 1)) + assert (sum(first), sum(second)) == (69, 68) + def test_tempo_change_between_rows_rebuilds_the_groove( self, controller: ProjectController, @@ -773,7 +779,7 @@ def test_tempo_change_between_rows_rebuilds_the_groove( after_change = _row_ticks(synthesizer, 1) assert at_reference_tempo == (speed,) - assert after_change == (_groove_ticks(controller)[1],) + assert after_change == (_groove_ticks(controller, FIRST_FRAME)[1],) def test_highlight_change_regroups_the_same_row_rate( self, @@ -812,7 +818,7 @@ def render_and_assert_chunk_uses_project_frequency( settings = controller.project.settings frame_length = round(settings.sample_rate / settings.nes_frequency) audio = _render(context) - assert len(audio) == frame_length * _groove_ticks(controller)[0] + assert len(audio) == frame_length * _groove_ticks(controller, FIRST_FRAME)[0] BaseTestScenario( label="frame length tracks the project NES frequency", diff --git a/tests/unit/sampletones_application/logic/sequencer/playback/test_tick_clock.py b/tests/unit/sampletones_application/logic/sequencer/playback/test_tick_clock.py index 630b93a2a..c39478e46 100644 --- a/tests/unit/sampletones_application/logic/sequencer/playback/test_tick_clock.py +++ b/tests/unit/sampletones_application/logic/sequencer/playback/test_tick_clock.py @@ -8,7 +8,7 @@ from sampletones_application.logic.sequencer.playback.synthesizer import RowSynthesizer from sampletones_core.configs import Config from sampletones_core.constants.enums import ChannelName -from sampletones_core.timing import MAX_TICKS_PER_ROW, MIN_TICKS_PER_ROW, Meter, RowRate, TickClock, calculate_groove +from sampletones_core.timing import SONG_TICK_BOUNDS, SongTiming, TickClock from tests.suite.base import BaseTestSuite from tests.suite.performance import make_pulse_reconstruction from tests.unit.sampletones_application.logic.sequencer.playback.conftest import ( @@ -26,13 +26,8 @@ def _expected_ticks(controller: ProjectController) -> Tuple[int, ...]: - settings = controller.project.settings - return calculate_groove( - RowRate.from_settings(settings), - Meter.from_settings(settings, rows=controller.project.song.rows_per_pattern), - minimum_ticks=MIN_TICKS_PER_ROW, - maximum_ticks=MAX_TICKS_PER_ROW, - ).ticks + """The ticks each row of the song's first frame lasts, where the synthesizer starts.""" + return SongTiming.from_project(controller.project, bounds=SONG_TICK_BOUNDS).groove(0).ticks class TestRowsFollowTheTickClock(BaseTestSuite): diff --git a/tests/unit/sampletones_core/exporters/test_exporter.py b/tests/unit/sampletones_core/exporters/test_exporter.py index 77523414a..4270f30ed 100644 --- a/tests/unit/sampletones_core/exporters/test_exporter.py +++ b/tests/unit/sampletones_core/exporters/test_exporter.py @@ -5,7 +5,7 @@ import pytest from sampletones_core.constants.enums import FeatureKey, GeneratorName -from sampletones_core.constants.general import MAX_VOLUME +from sampletones_core.constants.general import MAX_VOLUME, NUM_PERIODS from sampletones_core.exporters import ( ExporterTypeUnion, Features, @@ -16,9 +16,9 @@ from sampletones_core.features import ( CHANNEL_FEATURE_DEFAULTS, FEATURE_DIMENSION_ORDER, - supports, ) from sampletones_core.features.envelope import Envelope +from sampletones_core.features.spec import reads_from_instrument from sampletones_core.instructions import ( InstructionUnion, NoiseInstruction, @@ -471,8 +471,12 @@ class TestCase(BaseRegularTestCase): @property def unread(self) -> Tuple[FeatureKey, ...]: - """The dimensions this channel's generator reads nothing from.""" - return tuple(feature_key for feature_key in FEATURE_DIMENSION_ORDER if not supports(self.kind, feature_key)) + """The dimensions this channel reads nothing from, of a frame or of an instrument.""" + return tuple( + feature_key + for feature_key in FEATURE_DIMENSION_ORDER + if not reads_from_instrument(self.kind, feature_key) + ) test_cases = ( TestCase( @@ -560,6 +564,18 @@ def test_the_values_a_frame_states_sound_it_back(self, test_case: TestCase) -> N assert test_case.exporter.instruction_from_values(values, test_case.reference) == test_case.instruction + def test_a_bend_moves_the_noise_period_one_step_per_unit(self) -> None: + values = { + FeatureKey.VOLUME: NOISE_VOLUME, + FeatureKey.ARPEGGIO: PERIOD_STEP, + FeatureKey.PITCH: UNREAD_VALUE, + FeatureKey.DUTY_CYCLE: 1, + } + + frame = NoiseExporter.instruction_from_values(values, REFERENCE_PERIOD) + + assert frame.period == (REFERENCE_PERIOD + PERIOD_STEP + UNREAD_VALUE) % NUM_PERIODS + @pytest.mark.parametrize( "test_case", tuple(test_case for test_case in test_cases if test_case.unread), diff --git a/tests/unit/sampletones_core/features/test_spec.py b/tests/unit/sampletones_core/features/test_spec.py index e86ce81f1..d1120efd4 100644 --- a/tests/unit/sampletones_core/features/test_spec.py +++ b/tests/unit/sampletones_core/features/test_spec.py @@ -1,3 +1,5 @@ +from typing import Tuple + from sampletones_core.constants.enums import ( ChannelName, FeatureKey, @@ -14,6 +16,7 @@ supported_features, supports, ) +from sampletones_core.features.spec import reads_from_instrument from sampletones_core.formats.famitracker.specification.sequences import ( FEATURE_KEY_TO_SEQUENCE_KIND, SequenceKind, @@ -63,10 +66,24 @@ def test_supports_reports_triangle_lacks_duty_cycle() -> None: assert not supports(GeneratorName.TRIANGLE, FeatureKey.DUTY_CYCLE) -def test_supported_features_match_exporter_attribute_maps() -> None: - assert tuple(supported_features(GeneratorName.PULSE)) == tuple(PulseExporter._ATTRIBUTE_MAP) - assert tuple(supported_features(GeneratorName.TRIANGLE)) == tuple(TriangleExporter._ATTRIBUTE_MAP) - assert tuple(supported_features(GeneratorName.NOISE)) == tuple(NoiseExporter._ATTRIBUTE_MAP) +def test_exporter_attribute_maps_match_what_each_channel_reads() -> None: + def read(kind: GeneratorName) -> Tuple[FeatureKey, ...]: + return tuple(feature for feature in FEATURE_DIMENSION_ORDER if reads_from_instrument(kind, feature)) + + assert read(GeneratorName.PULSE) == tuple(PulseExporter._ATTRIBUTE_MAP) + assert read(GeneratorName.TRIANGLE) == tuple(TriangleExporter._ATTRIBUTE_MAP) + assert read(GeneratorName.NOISE) == tuple(NoiseExporter._ATTRIBUTE_MAP) + + +def test_a_channel_reads_every_dimension_its_generator_offers() -> None: + for kind in GeneratorName: + assert all(reads_from_instrument(kind, feature) for feature in supported_features(kind)) + + +def test_the_noise_channel_reads_an_instruments_bend_and_no_hi_pitch() -> None: + assert reads_from_instrument(GeneratorName.NOISE, FeatureKey.PITCH) + assert not reads_from_instrument(GeneratorName.NOISE, FeatureKey.HI_PITCH) + assert not reads_from_instrument(GeneratorName.TRIANGLE, FeatureKey.DUTY_CYCLE) def test_feature_dimension_order_matches_famitracker_sequence_slots() -> None: diff --git a/tests/unit/sampletones_core/formats/bitphase/test_bends.py b/tests/unit/sampletones_core/formats/bitphase/test_bends.py index fcae083fc..c3f7722e9 100644 --- a/tests/unit/sampletones_core/formats/bitphase/test_bends.py +++ b/tests/unit/sampletones_core/formats/bitphase/test_bends.py @@ -74,8 +74,9 @@ def test_a_slice_without_the_dimension_writes_no_offset(self) -> None: 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_noise_channel_moves_its_period_one_step_per_unit(self) -> None: + """Bitphase adds the offset to the noise note, and the note carries the period itself.""" + assert offsets(ChannelName.NOISE, bend=BEND_ENVELOPE) == BEND_ENVELOPE def test_the_bend_circles_from_the_point_it_states(self) -> None: written = features_to_envelopes( @@ -174,7 +175,7 @@ def test_an_unbent_slice_carries_its_contour_alone(self) -> None: 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: + def test_a_noise_slice_carries_its_bend_in_period_steps(self) -> None: preset = instrument_to_preset( build_instrument( "Hat", @@ -182,4 +183,4 @@ def test_a_noise_slice_takes_its_period_from_the_note(self) -> None: channel=ChannelName.NOISE, ), ) - assert set(preset.macros[NesMacroField.TONE_ADD].values) == {NO_TONE_OFFSET} + assert list(preset.macros[NesMacroField.TONE_ADD].values)[: len(BEND_ENVELOPE)] == BEND_ENVELOPE diff --git a/tests/unit/sampletones_core/formats/bitphase/test_preset.py b/tests/unit/sampletones_core/formats/bitphase/test_preset.py index 8e249adc9..07bb98bfc 100644 --- a/tests/unit/sampletones_core/formats/bitphase/test_preset.py +++ b/tests/unit/sampletones_core/formats/bitphase/test_preset.py @@ -97,7 +97,8 @@ def test_a_contour_reaching_past_the_tuning_table_holds_its_edge(self) -> None: ) 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: + def test_a_noise_slice_carries_its_contour_in_period_steps(self) -> None: + """Bitphase adds the offset to the noise note, which carries the period itself.""" preset = instrument_to_preset( build_instrument( "Hat", @@ -105,7 +106,7 @@ def test_a_noise_slice_takes_its_period_from_the_note(self) -> None: channel=ChannelName.NOISE, ), ) - assert set(offsets(preset)) == {NO_TONE_OFFSET} + assert list(offsets(preset))[:4] == [0, 1, 2, 3] class TestAPresetStaysAtConcertPitch: diff --git a/tests/unit/sampletones_core/formats/bitphase/test_project_builder.py b/tests/unit/sampletones_core/formats/bitphase/test_project_builder.py index 5ba137185..f855d241b 100644 --- a/tests/unit/sampletones_core/formats/bitphase/test_project_builder.py +++ b/tests/unit/sampletones_core/formats/bitphase/test_project_builder.py @@ -24,11 +24,10 @@ from sampletones_core.formats.bitphase.specification.channels import ChannelIndex from sampletones_core.formats.bitphase.specification.chip import DEFAULT_A4_TUNING, DEFAULT_CPU_FREQUENCY from sampletones_core.formats.bitphase.specification.effects import ( - NO_EFFECT_PARAMETER, + NO_EFFECT_TABLE, SPEED_EFFECT_DELAY, EffectId, ) -from sampletones_core.formats.bitphase.specification.instruments import LOOP_FROM_START from sampletones_core.formats.bitphase.specification.patterns import ( FIRST_OCTAVE, FULL_VOLUME, @@ -63,6 +62,7 @@ from sampletones_core.project.voices.voice import VoiceUnion, voice_reference from sampletones_core.reconstructions import Reconstruction from sampletones_core.structures import IdentifiedCollection +from sampletones_core.timing import SONG_TICK_BOUNDS, SongTiming from sampletones_core.utils.frequencies import transpose_pitch from tests.suite.base import BaseTestSuite from tests.suite.bitphase import BITPHASE_OPENING_PATTERN_VOLUME, pattern_volume @@ -342,57 +342,103 @@ def test_a_blank_line_leaves_every_column_alone(self, document: BitphaseProject) ) -class TestTheTempoBecomesAGroove: - """A Bitphase song holds one speed value per row, so the fractional row rate most tempi - ask for is carried by a groove: whole tick counts that vary from row to row. The groove - reaches the engine as a table a speed effect reads a row at a time, triggered from the - channel this exporter leaves silent. A tempo whose rows all last alike is carried by the - song's initial speed alone. +def played_speeds(document: BitphaseProject) -> List[Tuple[int, ...]]: + """The ticks each row of every pattern lasts, the order played once through. + + The song starts at its initial speed, and a speed effect sets the speed from its row on. + """ + song = document.songs[0] + patterns = {pattern.id: pattern for pattern in song.patterns} + speed = song.initial_speed + played: List[Tuple[int, ...]] = [] + for pattern_id in document.pattern_order: + pattern = patterns[pattern_id] + rows: List[int] = [] + for row_index in range(pattern.length): + for channel in pattern.channels: + for effect in channel.rows[row_index].effects: + if effect is not None and effect.effect == int(EffectId.SPEED): + speed = effect.parameter + + rows.append(speed) + + played.append(tuple(rows)) + + return played + + +class TestTheTempoBecomesSpeeds: + """A Bitphase song holds one speed value per row, so the fractional row rate most tempi ask for + is carried by speed effects that change it where the rows' lengths change. They ride the channel + this exporter leaves silent. A tempo whose rows all last alike is carried by the song's initial + speed alone. """ - def test_a_tempo_the_speed_column_states_needs_no_table(self, document: BitphaseProject) -> None: + def test_the_document_holds_one_table_per_slice( + self, + document: BitphaseProject, + grooved_document: BitphaseProject, + ) -> None: assert len(document.tables) == len(document.instruments) + assert len(grooved_document.tables) == len(grooved_document.instruments) - def test_a_tempo_the_speed_column_states_leaves_the_groove_channel_resting( + def test_a_tempo_the_speed_column_states_leaves_the_speed_channel_resting( self, document: BitphaseProject, ) -> None: - assert all(row == BitphaseRow() for row in groove_channel_rows(document, 0)) - - def test_a_groove_takes_the_table_above_the_slices(self, grooved_document: BitphaseProject) -> None: - table = grooved_document.tables[-1] - assert table.id == len(grooved_document.instruments) - assert table.loop == LOOP_FROM_START - - def test_the_table_holds_the_ticks_each_row_lasts(self, grooved_document: BitphaseProject) -> None: - assert grooved_document.tables[-1].rows == GROOVE_TICKS + for pattern_index in range(len(document.songs[0].patterns)): + assert all(row == BitphaseRow() for row in groove_channel_rows(document, pattern_index)) def test_the_song_starts_on_the_ticks_its_first_row_lasts(self, grooved_document: BitphaseProject) -> None: assert grooved_document.songs[0].initial_speed == GROOVE_TICKS[TRIGGER_ROW] - def test_every_pattern_triggers_the_groove_on_its_first_row(self, grooved_document: BitphaseProject) -> None: - """The speed table advances one entry per row and returns to the entry the trigger - names, so triggering it again at each pattern start holds every row on the entry that - describes it however the order jumps. - """ - trigger = EffectCell( - effect=int(EffectId.SPEED), - delay=SPEED_EFFECT_DELAY, - parameter=NO_EFFECT_PARAMETER, - table_index=grooved_document.tables[-1].id, + def test_every_row_plays_the_ticks_the_songs_timing_gives_it( + self, + grooved_document: BitphaseProject, + source: Project, + ) -> None: + """At tempo 210 an 8-row pattern lasts 34 2/7 ticks, so the second frame plays one tick more.""" + timing = SongTiming.from_project(source, bounds=SONG_TICK_BOUNDS) + expected = [timing.groove(frame).ticks for frame in range(source.song.order_length())] + + assert played_speeds(grooved_document) == expected + assert expected[0] == GROOVE_TICKS + assert sum(expected[1]) == sum(GROOVE_TICKS) + 1 + + def test_a_speed_is_stated_only_where_a_row_lasts_differently_from_the_row_before( + self, + grooved_document: BitphaseProject, + ) -> None: + """The song comes round to its first row after the last, so that is the row before the first.""" + speeds = [speed for pattern in played_speeds(grooved_document) for speed in pattern] + changes = sum(1 for index, speed in enumerate(speeds) if speed != speeds[index - 1]) + stated = sum( + 1 + for pattern_index in range(len(grooved_document.songs[0].patterns)) + for row in groove_channel_rows(grooved_document, pattern_index) + if row != BitphaseRow() ) - triggers = [ - groove_channel_rows(grooved_document, index)[TRIGGER_ROW].effects - for index in range(len(grooved_document.songs[0].patterns)) - ] - assert triggers == [(trigger,)] * len(triggers) - def test_the_groove_channel_carries_nothing_but_the_trigger(self, grooved_document: BitphaseProject) -> None: - rows = groove_channel_rows(grooved_document, 0) - assert all(row == BitphaseRow() for row in rows[TRIGGER_ROW + 1 :]) + assert stated == changes + + def test_the_speed_channel_carries_nothing_but_speeds(self, grooved_document: BitphaseProject) -> None: + speeds = {GROOVE_TICKS[TRIGGER_ROW], *GROOVE_TICKS} + for pattern_index in range(len(grooved_document.songs[0].patterns)): + for row in groove_channel_rows(grooved_document, pattern_index): + if row == BitphaseRow(): + continue + + (effect,) = row.effects + assert effect is not None + assert (effect.effect, effect.delay, effect.table_index) == ( + int(EffectId.SPEED), + SPEED_EFFECT_DELAY, + NO_EFFECT_TABLE, + ) + assert effect.parameter in speeds def test_the_sounding_channels_keep_their_effect_columns(self, grooved_document: BitphaseProject) -> None: - """The groove rides the silent channel, so every channel that plays keeps the one + """The speeds ride the silent channel, so every channel that plays keeps the one effect column the chip gives it. """ channels = grooved_document.songs[0].patterns[0].channels[: int(ChannelIndex.DPCM)] diff --git a/tests/unit/sampletones_core/formats/bitphase/test_transposes.py b/tests/unit/sampletones_core/formats/bitphase/test_transposes.py index cecb71674..04a0a4a41 100644 --- a/tests/unit/sampletones_core/formats/bitphase/test_transposes.py +++ b/tests/unit/sampletones_core/formats/bitphase/test_transposes.py @@ -6,7 +6,7 @@ from sampletones_core.constants.enums import ChannelName from sampletones_core.constants.general import MAX_PERIOD, MIN_PITCH, NUM_PERIODS from sampletones_core.formats.bitphase.btp import project_to_bytes -from sampletones_core.formats.bitphase.builder import project_to_bitphase +from sampletones_core.formats.bitphase.builder import BITPHASE_TICK_BOUNDS, project_to_bitphase from sampletones_core.formats.bitphase.model.pattern import BitphaseRow, EffectCell from sampletones_core.formats.bitphase.model.project import BitphaseProject from sampletones_core.formats.bitphase.model.table import BitphaseTable @@ -33,6 +33,7 @@ from sampletones_core.project.project import Project from sampletones_core.project.settings import ProjectSettings from sampletones_core.project.voices.sample import Sample +from sampletones_core.timing import SongTiming from tests.suite.base import BaseTestSuite from tests.suite.bitphase import noise_register, note_value, parse_btp, played_notes from tests.suite.case import BaseRegularTestCase @@ -85,11 +86,9 @@ def named_table(document: BitphaseProject, row: BitphaseRow) -> BitphaseTable: return next(table for table in document.tables if table.id == row.table - TABLE_COLUMN_OFFSET) -def groove_ticks(document: BitphaseProject) -> Tuple[int, ...]: - """The ticks each row lasts, read from the table the groove trigger on the DPCM channel names.""" - trigger = document.songs[0].patterns[0].channels[int(ChannelIndex.DPCM)].rows[0].effects[0] - assert trigger is not None - return next(table for table in document.tables if table.id == trigger.table_index).rows +def frame_ticks(project: Project, frame: int) -> Tuple[int, ...]: + """The ticks each row of a frame lasts in the document, as the song's timing gives them.""" + return SongTiming.from_project(project, bounds=BITPHASE_TICK_BOUNDS).groove(frame).ticks def ornament(position: int) -> Tuple[Optional[EffectCell], ...]: @@ -270,11 +269,10 @@ class TestTheStepANoteHasReached: """ def test_a_groove_counts_each_row_at_its_own_length(self, lead: Sample) -> None: - document = project_to_bitphase( - moved_project(lead, (0, note(lead)), (RAISED_ROW, Row(transpose=RAISED)), settings=GROOVE_SETTINGS) - ) + project = moved_project(lead, (0, note(lead)), (RAISED_ROW, Row(transpose=RAISED)), settings=GROOVE_SETTINGS) + document = project_to_bitphase(project) - assert channel_row(document, 0, RAISED_ROW).effects == ornament(sum(groove_ticks(document)[:RAISED_ROW])) + assert channel_row(document, 0, RAISED_ROW).effects == ornament(sum(frame_ticks(project, 0)[:RAISED_ROW])) def test_the_rows_of_every_frame_between_are_counted(self, lead: Sample) -> None: """A frame leaving the channel empty plays on with the note it carries.""" @@ -286,9 +284,12 @@ def test_the_rows_of_every_frame_between_are_counted(self, lead: Sample) -> None settings=GROOVE_SETTINGS, ) document = project_to_bitphase(project) - ticks = groove_ticks(document) - elapsed = sum(ticks[LATE_NOTE_ROW:]) + sum(ticks) + sum(ticks[:RAISED_ROW]) + elapsed = ( + sum(frame_ticks(project, 0)[LATE_NOTE_ROW:]) + + sum(frame_ticks(project, 1)) + + sum(frame_ticks(project, 2)[:RAISED_ROW]) + ) assert channel_row(document, 2, RAISED_ROW).effects == ornament(elapsed) diff --git a/tests/unit/sampletones_core/formats/famitracker/sequences/test_features.py b/tests/unit/sampletones_core/formats/famitracker/sequences/test_features.py index 3d909c8ec..1d53f0e03 100644 --- a/tests/unit/sampletones_core/formats/famitracker/sequences/test_features.py +++ b/tests/unit/sampletones_core/formats/famitracker/sequences/test_features.py @@ -72,12 +72,13 @@ def test_a_bend_without_an_arpeggio_gains_a_repeating_one(self) -> None: assert arpeggio.items == (0,) assert arpeggio.loop_point == LOOP_FROM_START - def test_an_arpeggio_shorter_than_the_bend_reaches_its_length(self) -> None: - arpeggio = features_to_instrument_sequences(build([15, 0], [4, 7], pitch=[1, 2, 3, 4]), repitched=False)[ + def test_an_arpeggio_shorter_than_a_bend_ending_with_no_offset_reaches_its_length(self) -> None: + arpeggio = features_to_instrument_sequences(build([15, 0], [4, 7], pitch=[1, 2, 3, 0]), repitched=False)[ SequenceKind.ARPEGGIO ] assert arpeggio.items == (4, 7, 7, 7) + assert arpeggio.loop_point == NO_LOOP_POINT def test_an_arpeggio_that_repeats_is_left_as_it_stands(self) -> None: sequences = features_to_instrument_sequences( @@ -89,11 +90,34 @@ def test_an_arpeggio_that_repeats_is_left_as_it_stands(self) -> None: assert sequences[SequenceKind.ARPEGGIO].loop_point == LOOP_FROM_START def test_an_arpeggio_already_covering_the_bend_is_left_as_it_stands(self) -> None: - arpeggio = features_to_instrument_sequences(build([15, 0], [4, 7, 9], pitch=[1, 2]), repitched=False)[ + arpeggio = features_to_instrument_sequences(build([15, 0], [4, 7, 9], pitch=[1, 0]), repitched=False)[ SequenceKind.ARPEGGIO ] - assert arpeggio.items == (4, 7, 9) + assert (arpeggio.items, arpeggio.loop_point) == ((4, 7, 9), NO_LOOP_POINT) + + def test_a_bend_ending_on_an_offset_holds_it_with_the_arpeggio_running_on(self) -> None: + """The voice holds a bend's last offset, which the tracker re-adds on every tick the arpeggio reloads.""" + sequences = features_to_instrument_sequences(build([15, 0], [], pitch=[0, 1, 2]), repitched=False) + arpeggio, pitch = sequences[SequenceKind.ARPEGGIO], sequences[SequenceKind.PITCH] + + assert (pitch.items, pitch.loop_point) == ((0, 1, 2), 2) + assert (arpeggio.items, arpeggio.loop_point) == ((0,), LOOP_FROM_START) + + def test_a_repeating_bend_keeps_an_arpeggio_playing_once_running(self) -> None: + """A repeating bend acts all through the note, and a halted arpeggio would let it pile up.""" + features = Features( + initial_pitch=REFERENCE_PITCH, + volume=envelope([15, 0]), + arpeggio=envelope([4, 7]), + pitch=envelope([1, -1], LOOP_FROM_START), + hi_pitch=None, + duty_cycle=None, + ) + + arpeggio = features_to_instrument_sequences(features, repitched=False)[SequenceKind.ARPEGGIO] + + assert (arpeggio.items, arpeggio.loop_point) == ((4, 7), 1) def test_an_instrument_writing_no_bend_gains_no_arpeggio(self) -> None: arpeggio = features_to_instrument_sequences(build([15, 0], []), repitched=False)[SequenceKind.ARPEGGIO] diff --git a/tests/unit/sampletones_core/formats/famitracker/test_slides.py b/tests/unit/sampletones_core/formats/famitracker/test_slides.py index 023760a47..9db581d3f 100644 --- a/tests/unit/sampletones_core/formats/famitracker/test_slides.py +++ b/tests/unit/sampletones_core/formats/famitracker/test_slides.py @@ -27,6 +27,7 @@ from sampletones_core.project.project import Project from sampletones_core.project.settings import ProjectSettings from sampletones_core.project.voices.sample import Sample +from sampletones_core.timing.bounds import SONG_TICK_BOUNDS from sampletones_core.timing.song import SongTiming from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase @@ -313,7 +314,7 @@ def replayed(document: FamiTrackerModule, project: Project, channel_name: Channe notes = played_notes( parse_ftm(module_to_ftm_bytes(document)), int(CHANNEL_TO_ID[channel_name]), - SongTiming.from_project(project).groove().ticks, + SongTiming.from_project(project, bounds=SONG_TICK_BOUNDS), ) if channel_name == ChannelName.NOISE: return [None if value is None else MAX_PERIOD - value for value in notes] diff --git a/tests/unit/sampletones_core/formats/test_binary.py b/tests/unit/sampletones_core/formats/test_binary.py index a25b8f266..c07d0db3c 100644 --- a/tests/unit/sampletones_core/formats/test_binary.py +++ b/tests/unit/sampletones_core/formats/test_binary.py @@ -112,6 +112,8 @@ def label(self) -> str: TestCase(method="uint8", expected=255), TestCase(method="int8", expected=-128), TestCase(method="int8", expected=127), + TestCase(method="uint16", expected=0x8000), + TestCase(method="uint16", expected=65535), TestCase(method="uint32", expected=0x0440), TestCase(method="uint32", expected=4294967295), TestCase(method="int32", expected=-1), @@ -124,6 +126,7 @@ def written(test_case: TestCase) -> bytes: { "uint8": writer.write_uint8, "int8": writer.write_int8, + "uint16": writer.write_uint16, "uint32": writer.write_uint32, "int32": writer.write_int32, }[test_case.method](test_case.expected) @@ -134,6 +137,7 @@ def read(reader: BinaryReader, method: str) -> int: return { "uint8": reader.read_uint8, "int8": reader.read_int8, + "uint16": reader.read_uint16, "uint32": reader.read_uint32, "int32": reader.read_int32, }[method]() @@ -165,6 +169,11 @@ def test_the_remainder_counts_down(self) -> None: reader.read_uint8() assert reader.remaining == 3 + def test_a_skip_moves_past_the_count_asked_for(self) -> None: + reader = BinaryReader(b"abcdef") + reader.skip(4) + assert reader.read_bytes(2) == b"ef" + def test_read_bytes_takes_the_count_asked_for(self) -> None: reader = BinaryReader(b"abcdef") assert reader.read_bytes(3) == b"abc" @@ -191,6 +200,11 @@ def test_a_field_wider_than_the_remainder_raises(self) -> None: with pytest.raises(TruncatedDataError): reader.read_uint32() + def test_a_skip_past_the_end_raises(self) -> None: + reader = BinaryReader(b"abc") + with pytest.raises(TruncatedDataError): + reader.skip(4) + def test_more_bytes_than_held_raises(self) -> None: reader = BinaryReader(b"abc") with pytest.raises(TruncatedDataError): diff --git a/tests/unit/sampletones_core/performance/test_song.py b/tests/unit/sampletones_core/performance/test_song.py index 37b1a329b..6c2345715 100644 --- a/tests/unit/sampletones_core/performance/test_song.py +++ b/tests/unit/sampletones_core/performance/test_song.py @@ -18,7 +18,7 @@ from sampletones_core.project.settings import ProjectSettings from sampletones_core.project.voices.envelopes import InstrumentEnvelopes from sampletones_core.project.voices.instrument import Instrument -from sampletones_core.timing import SongTiming +from sampletones_core.timing import SONG_TICK_BOUNDS, SongTiming from sampletones_shared.exceptions import OperationCanceled from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase @@ -71,14 +71,26 @@ def _resting(channel_name: ChannelName) -> object: class TestSongInstructions: """The four streams a song plays out as, one instruction per channel per engine tick.""" - def test_the_song_lasts_the_ticks_its_groove_gives_every_row_it_plays(self) -> None: + def test_the_song_lasts_the_ticks_its_timing_gives_every_row_it_plays(self) -> None: project = _project() - groove = SongTiming.from_project(project).groove() + timing = SongTiming.from_project(project, bounds=SONG_TICK_BOUNDS) streams = song_instructions(project) - expected_ticks = project.song.order_length() * groove.total_ticks - assert len(streams[ChannelName.PULSE1]) == expected_ticks + assert len(streams[ChannelName.PULSE1]) == timing.frame_tick(project.song.order_length()) + + def test_a_row_starts_on_the_tick_the_timing_places_it_at(self) -> None: + """At tempo 125 a row lasts 7.2 ticks, so a pattern played again starts where its frame's bar line falls.""" + project = _project() + project.settings = SETTINGS.model_copy(update={"tempo": 125}) + project.song.append_frame() + project.song.set_order_entry(1, ChannelName.PULSE1, 0) + start = SongTiming.from_project(project, bounds=SONG_TICK_BOUNDS).tick_at(1, 0) + + stream = song_instructions(project)[ChannelName.PULSE1] + + assert stream[start - 1] == _resting(ChannelName.PULSE1) + assert stream[start] == stream[0] def test_every_channel_answers_for_every_tick(self) -> None: """One length across the four streams is what makes a tick index one moment of the song.""" @@ -104,12 +116,12 @@ def test_a_pattern_the_order_plays_twice_sounds_alike_both_times(self) -> None: project = _project() project.song.append_frame() project.song.set_order_entry(1, ChannelName.PULSE1, 0) - groove = SongTiming.from_project(project).groove() + timing = SongTiming.from_project(project, bounds=SONG_TICK_BOUNDS) stream = song_instructions(project)[ChannelName.PULSE1] - frame_ticks = groove.total_ticks - assert stream[:frame_ticks] == stream[frame_ticks : 2 * frame_ticks] + frame_ticks = timing.frame_tick(1) + assert stream[:frame_ticks] == stream[frame_ticks : timing.frame_tick(2)] class TestWhatAWalkSaysAboutItself: @@ -122,11 +134,11 @@ def test_the_walk_reports_each_row_it_sounds(self) -> None: assert len(heard) == project.song.order_length() * project.song.rows_per_pattern def test_the_walk_states_the_ticks_the_order_lasts(self) -> None: - """The groove states the length before a row is played, so every report names the same.""" + """The timing states the length before a row is played, so every report names the same.""" project = _project() heard: List[WalkProgress] = [] song_instructions(project, lambda progress: heard.append(progress) is None) - expected = project.song.order_length() * SongTiming.from_project(project).groove().total_ticks + expected = SongTiming.from_project(project, bounds=SONG_TICK_BOUNDS).frame_tick(project.song.order_length()) assert [progress.total for progress in heard] == [expected] * len(heard) def test_the_walk_reaches_the_ticks_it_set_out_to_sound(self) -> None: @@ -168,7 +180,7 @@ def _following( place_instrument(project, channel_name=channel_name, row_index=1, sample=second, volume=volume) stream = song_instructions(project)[channel_name] - return stream[SongTiming.from_project(project).groove().ticks[0]] + return stream[SongTiming.from_project(project, bounds=SONG_TICK_BOUNDS).row_ticks(0, 0)] class TestANoteStartsFromWhereASongStarts(BaseTestSuite): diff --git a/tests/unit/sampletones_core/project/voices/test_instrument.py b/tests/unit/sampletones_core/project/voices/test_instrument.py index ec452b8c3..3febb52fd 100644 --- a/tests/unit/sampletones_core/project/voices/test_instrument.py +++ b/tests/unit/sampletones_core/project/voices/test_instrument.py @@ -1,11 +1,11 @@ from dataclasses import dataclass -from typing import Tuple +from typing import List, Tuple import pytest from pydantic import ValidationError from sampletones_core.constants.enums import ChannelName, FeatureKey -from sampletones_core.constants.general import MAX_VOLUME +from sampletones_core.constants.general import MAX_VOLUME, NUM_PERIODS from sampletones_core.features import ( RESTING_REFERENCE_PERIOD, RESTING_REFERENCE_PITCH, @@ -25,6 +25,12 @@ DUTY_CYCLE: Tuple[int, ...] = (2,) +def _noise_frames(instrument: Instrument) -> List[NoiseInstruction]: + frames = instrument.instructions(ChannelName.NOISE) + assert all(isinstance(frame, NoiseInstruction) for frame in frames) + return [frame for frame in frames if isinstance(frame, NoiseInstruction)] + + def _instrument(**overrides: object) -> Instrument: fields: dict = { "name": "lead", @@ -125,16 +131,55 @@ def test_a_triangle_frame_sounds_at_the_root(self) -> None: assert first == TriangleInstruction(on=True, pitch=RESTING_REFERENCE_PITCH) - def test_a_noise_frame_takes_the_period_root_and_the_short_mode(self) -> None: + def test_a_noise_frame_takes_the_period_root_and_the_long_mode_of_an_even_duty(self) -> None: + """FamiTracker and Bitphase read the duty cycle's lowest bit as the noise mode, so duty 2 plays long.""" first = _instrument().instructions(ChannelName.NOISE)[0] assert first == NoiseInstruction( on=True, period=RESTING_REFERENCE_PERIOD, volume=VOLUME[0], - short=True, + short=False, + ) + + def test_an_odd_duty_plays_the_short_noise_mode(self) -> None: + instrument = _instrument( + envelopes=InstrumentEnvelopes(volume=Envelope(items=VOLUME), duty_cycle=Envelope(items=(1, 3, 2))) ) + assert [frame.short for frame in _noise_frames(instrument)] == [True, True, False, False] + + def test_a_bend_moves_the_noise_period_one_step_per_unit(self) -> None: + bend = (0, 1, -2, 5) + instrument = _instrument( + envelopes=InstrumentEnvelopes(volume=Envelope(items=VOLUME), pitch=Envelope(items=bend)) + ) + + assert [frame.period for frame in _noise_frames(instrument)] == [ + (RESTING_REFERENCE_PERIOD + step) % NUM_PERIODS for step in bend + ] + + def test_a_bend_adds_to_the_arpeggio_on_noise(self) -> None: + instrument = _instrument( + envelopes=InstrumentEnvelopes( + volume=Envelope(items=VOLUME), + arpeggio=Envelope(items=(0, 4)), + pitch=Envelope(items=(3, 3)), + ) + ) + + assert [frame.period for frame in _noise_frames(instrument)][:2] == [ + RESTING_REFERENCE_PERIOD + 3, + RESTING_REFERENCE_PERIOD + 7, + ] + + def test_hi_pitch_moves_the_noise_period_a_whole_turn_which_leaves_it_where_it_was(self) -> None: + instrument = _instrument( + envelopes=InstrumentEnvelopes(volume=Envelope(items=VOLUME), hi_pitch=Envelope(items=(1, 2))) + ) + + assert {frame.period for frame in _noise_frames(instrument)} == {RESTING_REFERENCE_PERIOD} + def test_an_instrument_writing_nothing_sounds_on_no_channel(self) -> None: instrument = Instrument(name="empty") @@ -163,6 +208,13 @@ def test_an_empty_envelope_is_left_to_the_channel(self) -> None: assert FeatureKey.DUTY_CYCLE in held assert FeatureKey.ARPEGGIO not in held + def test_a_bend_on_noise_decides_the_arpeggio_it_rides_on(self) -> None: + """A noise frame carries the arpeggio and the bend as one period, so the bend writes the arpeggio there.""" + instrument = Instrument(name="sweep", envelopes=InstrumentEnvelopes(pitch=Envelope(items=(1, 2, 3)))) + + assert FeatureKey.ARPEGGIO not in instrument.held_features(ChannelName.NOISE) + assert FeatureKey.ARPEGGIO in instrument.held_features(ChannelName.PULSE1) + def test_a_channel_is_told_of_the_dimensions_it_offers_alone(self) -> None: instrument = Instrument(name="lead", envelopes=InstrumentEnvelopes(arpeggio=Envelope(items=ARPEGGIO))) diff --git a/tests/unit/sampletones_core/timing/test_distribution.py b/tests/unit/sampletones_core/timing/test_distribution.py index f659f4b85..2948e2dfe 100644 --- a/tests/unit/sampletones_core/timing/test_distribution.py +++ b/tests/unit/sampletones_core/timing/test_distribution.py @@ -1,163 +1,107 @@ from dataclasses import dataclass -from typing import Tuple +from fractions import Fraction +from math import ceil, floor +from typing import Final, Tuple import pytest -from sampletones_core.timing.distribution import distribute_by_halving, distribute_proportionally +from sampletones_core.timing.distribution import nearest, split_by_halving from tests.suite.base import BaseTestSuite from tests.suite.case import BaseAutolabelTestCase +from tests.suite.groove import is_proportional, surplus_rows +COMMON_BEAT: Final[int] = 4 +BEATS_IN_A_LONG_BAR: Final[int] = 8 -class TestDistributeProportionally(BaseTestSuite): - @dataclass(frozen=True, kw_only=True) - class TestCase(BaseAutolabelTestCase): - expected: Tuple[int, ...] - total: int - lengths: Tuple[int, ...] - - @property - def label(self) -> str: - spans = "_".join(str(length) for length in self.lengths) - return f"total_{self.total}_over_{spans}" - - test_cases = ( - TestCase( - total=69, - lengths=(4, 4, 4, 4), - expected=(18, 17, 17, 17), - ), - TestCase( - total=69, - lengths=(8, 8), - expected=(35, 34), - ), - TestCase( - total=274, - lengths=(16, 16, 16, 16), - expected=(69, 68, 69, 68), - ), - TestCase( - total=17, - lengths=(2, 2), - expected=(9, 8), - ), - TestCase( - total=18, - lengths=(2, 2), - expected=(9, 9), - ), - TestCase( - total=100, - lengths=(1,), - expected=(100,), - ), - TestCase( - total=0, - lengths=(4, 4), - expected=(0, 0), - ), - TestCase( - total=69, - lengths=(12, 4), - expected=(52, 17), - ), - TestCase( - total=10, - lengths=(1, 1, 1), - expected=(4, 3, 3), - ), - TestCase( - total=11, - lengths=(1, 1, 1), - expected=(4, 4, 3), - ), - ) +class TestNearest(BaseTestSuite): @pytest.mark.parametrize( - "test_case", - test_cases, - ids=lambda test_case: test_case.label, + ("value", "expected"), + ( + (Fraction(7, 2), 4), + (Fraction(36, 5), 7), + (Fraction(38, 5), 8), + (Fraction(-1, 2), 0), + (Fraction(6), 6), + ), ) - def test_shares_match(self, test_case: TestCase) -> None: - shares = distribute_proportionally(test_case.total, test_case.lengths) - assert shares == test_case.expected - assert sum(shares) == test_case.total + def test_a_half_rounds_up(self, value: Fraction, expected: int) -> None: + assert nearest(value) == expected - def test_no_span_is_rejected(self) -> None: - with pytest.raises(ValueError, match="At least one span"): - distribute_proportionally(10, ()) - def test_empty_span_is_rejected(self) -> None: - with pytest.raises(ValueError, match="at least 1 row"): - distribute_proportionally(10, (4, 0)) - - -class TestDistributeByHalving(BaseTestSuite): +class TestSplitByHalving(BaseTestSuite): @dataclass(frozen=True, kw_only=True) class TestCase(BaseAutolabelTestCase): expected: Tuple[int, ...] total: int - rows: int + spans: Tuple[int, ...] + row_ticks: Fraction @property def label(self) -> str: - return f"total_{self.total}_over_{self.rows}_rows" + spans = "_".join(str(span) for span in self.spans) + return f"total_{self.total}_over_{spans}" test_cases = ( TestCase( - total=22, - rows=4, - expected=(6, 5, 6, 5), + total=115, + spans=(4, 4, 4, 4), + row_ticks=Fraction(36, 5), + expected=(8, 7, 7, 7, 8, 7, 7, 7, 8, 7, 7, 7, 7, 7, 7, 7), ), TestCase( total=69, - rows=16, + spans=(4, 4, 4, 4), + row_ticks=Fraction(30, 7), expected=(5, 4, 5, 4, 5, 4, 4, 4, 5, 4, 4, 4, 5, 4, 4, 4), ), TestCase( - total=18, - rows=4, - expected=(5, 4, 5, 4), - ), - TestCase( - total=17, - rows=4, - expected=(5, 4, 4, 4), + total=24, + spans=(4,), + row_ticks=Fraction(6), + expected=(6, 6, 6, 6), ), TestCase( - total=9, - rows=2, - expected=(5, 4), + total=29, + spans=(4,), + row_ticks=Fraction(29, 4), + expected=(8, 7, 7, 7), ), TestCase( - total=100, - rows=1, - expected=(100,), + total=30, + spans=(4,), + row_ticks=Fraction(30, 4), + expected=(8, 7, 8, 7), ), TestCase( - total=0, - rows=5, - expected=(0, 0, 0, 0, 0), + total=31, + spans=(4,), + row_ticks=Fraction(31, 4), + expected=(8, 8, 8, 7), ), TestCase( - total=13, - rows=3, - expected=(5, 4, 4), + total=22, + spans=(3,), + row_ticks=Fraction(22, 3), + expected=(8, 7, 7), ), TestCase( - total=22, - rows=5, - expected=(5, 5, 4, 4, 4), + total=23, + spans=(3,), + row_ticks=Fraction(23, 3), + expected=(8, 7, 8), ), TestCase( - total=30, - rows=7, - expected=(5, 4, 5, 4, 4, 4, 4), + total=100, + spans=(1,), + row_ticks=Fraction(100), + expected=(100,), ), TestCase( - total=52, - rows=12, - expected=(5, 4, 4, 5, 4, 4, 5, 4, 4, 5, 4, 4), + total=50, + spans=(3, 3, 1), + row_ticks=Fraction(36, 5), + expected=(8, 7, 7, 7, 7, 7, 7), ), ) @@ -167,15 +111,72 @@ def label(self) -> str: ids=lambda test_case: test_case.label, ) def test_ticks_match(self, test_case: TestCase) -> None: - ticks = distribute_by_halving(test_case.total, test_case.rows) + ticks = split_by_halving(test_case.total, test_case.spans, row_ticks=test_case.row_ticks) + assert ticks == test_case.expected assert sum(ticks) == test_case.total - def test_absent_rows_are_rejected(self) -> None: - with pytest.raises(ValueError, match="rows must be at least 1"): - distribute_by_halving(10, 0) + @pytest.mark.parametrize( + ("rows", "strongest"), + ( + (2, (0, 1)), + (3, (0, 2, 1)), + (4, (0, 2, 1, 3)), + (6, (0, 3, 2, 5, 1, 4)), + (8, (0, 4, 2, 6, 1, 5, 3, 7)), + ), + ) + def test_a_beat_gives_its_surplus_to_its_strongest_rows(self, rows: int, strongest: Tuple[int, ...]) -> None: + """The first row takes the first surplus tick, the middle row the next, the quarters after them.""" + for surplus in range(1, rows): + row_ticks = 7 + Fraction(surplus, rows) + ticks = split_by_halving(7 * rows + surplus, (rows,), row_ticks=row_ticks) + + assert surplus_rows(ticks, row_ticks) == tuple(sorted(strongest[:surplus])) + + @pytest.mark.parametrize("surplus", range(1, BEATS_IN_A_LONG_BAR)) + def test_a_bar_gives_its_surplus_to_its_strongest_beats(self, surplus: int) -> None: + """Eight beats share their surplus ticks as the first half of them, then the halves of those.""" + strongest = (0, 4, 2, 6, 1, 5, 3, 7) + rows = COMMON_BEAT * BEATS_IN_A_LONG_BAR + row_ticks = 7 + Fraction(surplus, rows) + ticks = split_by_halving( + 7 * rows + surplus, + (COMMON_BEAT,) * BEATS_IN_A_LONG_BAR, + row_ticks=row_ticks, + ) + longer_beats = tuple( + beat + for beat in range(BEATS_IN_A_LONG_BAR) + if sum(ticks[beat * COMMON_BEAT : (beat + 1) * COMMON_BEAT]) > 7 * COMMON_BEAT + ) + + assert longer_beats == tuple(sorted(strongest[:surplus])) + + @pytest.mark.parametrize("spans", ((4, 4, 4, 4), (3, 3, 1), (5, 5, 5), (2,), (7,), (6, 6, 4), (1, 1, 1))) + @pytest.mark.parametrize("row_ticks", (Fraction(36, 5), Fraction(30, 7), Fraction(11, 4), Fraction(13, 10))) + def test_every_total_the_rows_carry_keeps_each_row_proportional( + self, + spans: Tuple[int, ...], + row_ticks: Fraction, + ) -> None: + rows = sum(spans) + for total in range(rows * floor(row_ticks), rows * ceil(row_ticks) + 1): + ticks = split_by_halving(total, spans, row_ticks=row_ticks) + + assert sum(ticks) == total + assert len(ticks) == rows + assert is_proportional(ticks, row_ticks) + + def test_no_span_is_rejected(self) -> None: + with pytest.raises(ValueError, match="At least one span"): + split_by_halving(10, (), row_ticks=Fraction(5)) + + def test_an_empty_span_is_rejected(self) -> None: + with pytest.raises(ValueError, match="at least 1 row"): + split_by_halving(10, (4, 0), row_ticks=Fraction(5, 2)) - @pytest.mark.parametrize("rows", (1, 2, 3, 4, 5, 7, 8, 12, 16, 31, 64)) - def test_earlier_rows_run_at_least_as_long(self, rows: int) -> None: - ticks = distribute_by_halving(rows * 4 + 1, rows) - assert ticks[0] == max(ticks) + @pytest.mark.parametrize("total", (27, 33)) + def test_a_total_the_rows_cannot_carry_is_rejected(self, total: int) -> None: + with pytest.raises(ValueError, match="carry no total"): + split_by_halving(total, (4,), row_ticks=Fraction(29, 4)) diff --git a/tests/unit/sampletones_core/timing/test_groove.py b/tests/unit/sampletones_core/timing/test_groove.py index 82933e00a..379349183 100644 --- a/tests/unit/sampletones_core/timing/test_groove.py +++ b/tests/unit/sampletones_core/timing/test_groove.py @@ -6,24 +6,35 @@ import pytest -from sampletones_core.timing.groove import Groove, calculate_groove +from sampletones_core.timing.bounds import TickBounds +from sampletones_core.timing.groove import Groove from sampletones_core.timing.meter import Meter from sampletones_core.timing.rate import RowRate +from sampletones_core.timing.song import SongTiming from sampletones_shared.constants.project import REFERENCE_NES_FREQUENCY, REFERENCE_TEMPO from tests.suite.base import BaseTestSuite from tests.suite.case import BaseAutolabelTestCase +from tests.suite.groove import bar_line_drift, bar_rows, is_proportional MINIMUM_TICKS: Final[int] = 1 MAXIMUM_TICKS: Final[int] = 255 +ENGINE_BOUNDS: Final[TickBounds] = TickBounds(minimum=MINIMUM_TICKS, maximum=MAXIMUM_TICKS) +FIRST_FRAME: Final[int] = 0 COMMON_TIME_BEAT: Final[int] = 4 COMMON_TIME_BAR: Final[int] = 16 REFERENCE_SPEED: Final[int] = 6 +HALF_TICK: Final[Fraction] = Fraction(1, 2) + + +def first_frame_groove(rate: RowRate, meter: Meter) -> Groove: + """The groove the song's first frame plays, where every bar line is counted from the pattern's first row.""" + return SongTiming(rate=rate, meter=meter, bounds=ENGINE_BOUNDS).groove(FIRST_FRAME) class TestGroove(BaseTestSuite): - """One case table, read both for the ticks it produces and for the rules they obey.""" + """One case table of the song's first frame, read both for the ticks it produces and for the rules they obey.""" @dataclass(frozen=True, kw_only=True) class TestCase(BaseAutolabelTestCase): @@ -51,17 +62,23 @@ def meter(self) -> Meter: ) @property - def groove(self) -> Groove: - return calculate_groove( - RowRate.from_parameters( - tempo=self.tempo, - speed=self.speed, - nes_frequency=self.nes_frequency, - ), - self.meter, + def rate(self) -> RowRate: + return RowRate.from_parameters( + tempo=self.tempo, + speed=self.speed, + nes_frequency=self.nes_frequency, + ) + + @property + def reachable_rate(self) -> Fraction: + return self.rate.bounded( minimum_ticks=MINIMUM_TICKS, maximum_ticks=MAXIMUM_TICKS, - ) + ).ticks_per_row + + @property + def groove(self) -> Groove: + return first_frame_groove(self.rate, self.meter) test_cases = ( TestCase( @@ -116,7 +133,7 @@ def groove(self) -> Groove: rows=12, first_highlight=3, second_highlight=12, - expected=(2, 2, 2, 2, 2, 1, 2, 2, 1, 2, 2, 1), + expected=(2, 2, 2, 2, 1, 2, 2, 1, 2, 2, 1, 2), ), TestCase( tempo=210, @@ -197,7 +214,7 @@ def groove(self) -> Groove: rows=7, first_highlight=2, second_highlight=3, - expected=(5, 5, 4, 5, 5, 4, 4), + expected=(5, 4, 5, 5, 4, 5, 4), ), TestCase( tempo=251, @@ -206,7 +223,7 @@ def groove(self) -> Groove: rows=17, first_highlight=5, second_highlight=7, - expected=(26, 26, 26, 26, 26, 26, 25, 26, 26, 26, 26, 25, 26, 25, 26, 26, 25), + expected=(26, 25, 26, 26, 26, 26, 25, 26, 25, 26, 26, 26, 26, 26, 26, 25, 26), ), TestCase( tempo=97, @@ -215,7 +232,7 @@ def groove(self) -> Groove: rows=9, first_highlight=4, second_highlight=6, - expected=(4, 3, 4, 3, 3, 3, 3, 3, 3), + expected=(4, 3, 3, 3, 3, 3, 4, 3, 3), ), TestCase( tempo=128, @@ -224,7 +241,7 @@ def groove(self) -> Groove: rows=15, first_highlight=4, second_highlight=16, - expected=(10, 9, 10, 9, 10, 9, 10, 9, 10, 9, 9, 9, 10, 9, 9), + expected=(10, 9, 10, 9, 10, 9, 9, 9, 10, 9, 10, 9, 10, 9, 9), ), TestCase( tempo=100, @@ -233,7 +250,7 @@ def groove(self) -> Groove: rows=13, first_highlight=7, second_highlight=7, - expected=(8, 8, 8, 8, 8, 8, 7, 8, 8, 8, 8, 8, 7), + expected=(8, 8, 8, 7, 8, 8, 8, 8, 8, 8, 8, 7, 8), ), TestCase( tempo=43, @@ -242,7 +259,7 @@ def groove(self) -> Groove: rows=11, first_highlight=3, second_highlight=8, - expected=(53, 53, 52, 53, 52, 52, 52, 52, 52, 52, 52), + expected=(53, 52, 52, 53, 52, 52, 52, 52, 53, 52, 52), ), TestCase( tempo=199, @@ -296,7 +313,7 @@ def groove(self) -> Groove: rows=7, first_highlight=4, second_highlight=16, - expected=(5, 4, 5, 4, 4, 4, 4), + expected=(5, 4, 4, 4, 5, 4, 4), ), TestCase( tempo=210, @@ -323,7 +340,7 @@ def groove(self) -> Groove: rows=13, first_highlight=4, second_highlight=16, - expected=(5, 4, 5, 4, 5, 4, 4, 4, 5, 4, 4, 4, 4), + expected=(5, 4, 4, 4, 5, 4, 4, 4, 5, 4, 5, 4, 4), ), TestCase( tempo=210, @@ -341,7 +358,7 @@ def groove(self) -> Groove: rows=23, first_highlight=4, second_highlight=16, - expected=(5, 4, 5, 4, 5, 4, 4, 4, 5, 4, 4, 4, 5, 4, 4, 4, 5, 4, 5, 4, 4, 4, 4), + expected=(5, 4, 5, 4, 5, 4, 4, 4, 5, 4, 4, 4, 5, 4, 4, 4, 5, 4, 4, 4, 5, 4, 4), ), TestCase( tempo=210, @@ -378,7 +395,7 @@ def groove(self) -> Groove: 4, 5, 4, - 5, + 4, 4, 5, 4, @@ -394,7 +411,7 @@ def groove(self) -> Groove: 4, 5, 4, - 4, + 5, 4, 5, 4, @@ -503,7 +520,7 @@ def groove(self) -> Groove: rows=16, first_highlight=1, second_highlight=1, - expected=(9, 9, 8, 9, 8, 9, 8, 9, 9, 8, 9, 8, 9, 8, 9, 8), + expected=(9, 8, 9, 8, 9, 8, 9, 9, 8, 9, 8, 9, 8, 9, 9, 8), ), TestCase( tempo=105, @@ -512,7 +529,7 @@ def groove(self) -> Groove: rows=16, first_highlight=2, second_highlight=4, - expected=(9, 9, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8), + expected=(9, 8, 9, 8, 9, 9, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8), ), TestCase( tempo=105, @@ -521,7 +538,7 @@ def groove(self) -> Groove: rows=16, first_highlight=3, second_highlight=4, - expected=(9, 9, 9, 8, 9, 9, 8, 8, 9, 9, 8, 8, 9, 9, 8, 8), + expected=(9, 8, 9, 8, 9, 8, 9, 9, 9, 8, 9, 8, 9, 8, 9, 8), ), TestCase( tempo=105, @@ -530,7 +547,7 @@ def groove(self) -> Groove: rows=16, first_highlight=5, second_highlight=3, - expected=(9, 9, 8, 9, 9, 8, 9, 9, 8, 9, 8, 8, 9, 9, 8, 8), + expected=(9, 8, 9, 9, 8, 8, 9, 8, 9, 9, 8, 9, 9, 8, 9, 8), ), TestCase( tempo=105, @@ -539,7 +556,7 @@ def groove(self) -> Groove: rows=16, first_highlight=16, second_highlight=4, - expected=(9, 9, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8), + expected=(9, 8, 9, 8, 9, 9, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8), ), TestCase( tempo=105, @@ -557,7 +574,7 @@ def groove(self) -> Groove: rows=12, first_highlight=4, second_highlight=6, - expected=(9, 9, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8), + expected=(9, 8, 9, 8, 9, 8, 9, 9, 9, 8, 9, 8), ), TestCase( tempo=105, @@ -575,7 +592,7 @@ def groove(self) -> Groove: rows=9, first_highlight=2, second_highlight=6, - expected=(9, 9, 9, 8, 9, 8, 9, 8, 8), + expected=(9, 8, 9, 8, 9, 8, 9, 8, 9), ), TestCase( tempo=105, @@ -584,7 +601,7 @@ def groove(self) -> Groove: rows=15, first_highlight=5, second_highlight=15, - expected=(9, 9, 8, 9, 8, 9, 9, 8, 9, 8, 9, 9, 8, 9, 8), + expected=(9, 8, 9, 9, 8, 9, 8, 9, 9, 8, 9, 8, 9, 9, 8), ), TestCase( tempo=150, @@ -712,29 +729,14 @@ def test_ticks_match(self, test_case: TestCase) -> None: def test_the_groove_fills_the_pattern(self, test_case: TestCase) -> None: assert len(test_case.groove.ticks) == test_case.rows - @pytest.mark.parametrize( - "test_case", - test_cases, - ids=lambda test_case: test_case.label, - ) - def test_the_total_and_the_mean_describe_the_rows(self, test_case: TestCase) -> None: - groove = test_case.groove - assert groove.total_ticks == sum(groove.ticks) - assert groove.mean_ticks_per_row == Fraction(groove.total_ticks, test_case.rows) - @pytest.mark.parametrize( "test_case", test_cases, ids=lambda test_case: test_case.label, ) def test_the_rate_is_reached_as_closely_as_the_range_allows(self, test_case: TestCase) -> None: - rate = RowRate.from_parameters( - tempo=test_case.tempo, - speed=test_case.speed, - nes_frequency=test_case.nes_frequency, - ) - reachable = min(max(rate.ticks_per_row, MINIMUM_TICKS), MAXIMUM_TICKS) - assert abs(test_case.groove.mean_ticks_per_row - reachable) <= Fraction(1, 2 * test_case.rows) + exact = test_case.reachable_rate * test_case.rows + assert abs(sum(test_case.groove.ticks) - exact) <= HALF_TICK @pytest.mark.parametrize( "test_case", @@ -749,23 +751,21 @@ def test_every_row_lies_within_the_engine_range(self, test_case: TestCase) -> No test_cases, ids=lambda test_case: test_case.label, ) - def test_every_row_neighbors_the_average(self, test_case: TestCase) -> None: - groove = test_case.groove - shorter, remainder = divmod(groove.total_ticks, test_case.rows) - longer = shorter + 1 if remainder else shorter - assert set(groove.ticks) <= {shorter, longer} + def test_every_row_lasts_the_floor_or_the_ceiling_of_the_rate(self, test_case: TestCase) -> None: + assert is_proportional(test_case.groove.ticks, test_case.reachable_rate) @pytest.mark.parametrize( "test_case", test_cases, ids=lambda test_case: test_case.label, ) - def test_longer_rows_come_first(self, test_case: TestCase) -> None: - groove = test_case.groove - elapsed = 0 - for index, ticks in enumerate(groove.ticks, start=1): - elapsed += ticks - assert elapsed >= groove.total_ticks * index // test_case.rows + def test_every_bar_starts_on_the_tick_nearest_its_exact_start(self, test_case: TestCase) -> None: + drift = bar_line_drift( + test_case.groove.ticks, + bar_rows(test_case.meter, frames=1), + test_case.reachable_rate, + ) + assert drift <= HALF_TICK @pytest.mark.parametrize( "test_case", @@ -788,7 +788,7 @@ class TestReferenceCalibration(BaseTestSuite): @pytest.mark.parametrize("speed", tuple(range(1, 32))) @pytest.mark.parametrize("rows", (1, 2, 7, 16, 60, 64, 256)) def test_every_row_lasts_speed_ticks(self, speed: int, rows: int) -> None: - groove = calculate_groove( + groove = first_frame_groove( RowRate.from_parameters( tempo=REFERENCE_TEMPO, speed=speed, @@ -799,11 +799,8 @@ def test_every_row_lasts_speed_ticks(self, speed: int, rows: int) -> None: first_highlight=COMMON_TIME_BEAT, second_highlight=COMMON_TIME_BAR, ), - minimum_ticks=MINIMUM_TICKS, - maximum_ticks=MAXIMUM_TICKS, ) assert groove.ticks == (speed,) * rows - assert groove.is_uniform class TestSecondHighlight(BaseTestSuite): @@ -811,7 +808,7 @@ class TestSecondHighlight(BaseTestSuite): @staticmethod def _groove(rows: int, first_highlight: int, second_highlight: int, tempo: int) -> Groove: - return calculate_groove( + return first_frame_groove( RowRate.from_parameters( tempo=tempo, speed=REFERENCE_SPEED, @@ -822,8 +819,6 @@ def _groove(rows: int, first_highlight: int, second_highlight: int, tempo: int) first_highlight=first_highlight, second_highlight=second_highlight, ), - minimum_ticks=MINIMUM_TICKS, - maximum_ticks=MAXIMUM_TICKS, ) @pytest.mark.parametrize("second_highlight", (1, 2, 3, 4, 7, 8, 12, 16, 20, 32, 64)) @@ -832,72 +827,35 @@ def _groove(rows: int, first_highlight: int, second_highlight: int, tempo: int) def test_the_bar_leaves_the_tempo_alone(self, tempo: int, rows: int, second_highlight: int) -> None: grouped = self._groove(rows, COMMON_TIME_BEAT, second_highlight, tempo) pattern_wide = self._groove(rows, COMMON_TIME_BEAT, rows, tempo) - assert grouped.total_ticks == pattern_wide.total_ticks + assert sum(grouped.ticks) == sum(pattern_wide.ticks) def test_a_bar_cutting_across_the_beat_reorganizes_the_groove(self) -> None: - across = self._groove(4, 2, 3, 105) - pattern_wide = self._groove(4, 2, 4, 105) - assert across.ticks == (9, 9, 8, 8) - assert pattern_wide.ticks == (9, 8, 9, 8) - assert across.total_ticks == pattern_wide.total_ticks + across = self._groove(6, 2, 3, 105) + pattern_wide = self._groove(6, 2, 6, 105) + assert across.ticks == (9, 8, 9, 9, 8, 8) + assert pattern_wide.ticks == (9, 8, 9, 8, 9, 8) + assert sum(across.ticks) == sum(pattern_wide.ticks) def test_a_bar_shorter_than_the_beat_reorganizes_the_groove(self) -> None: across = self._groove(5, 5, 4, 105) aligned = self._groove(5, 5, 8, 105) - assert across.ticks == (9, 9, 9, 8, 8) - assert aligned.ticks == (9, 9, 8, 9, 8) - assert across.total_ticks == aligned.total_ticks + assert across.ticks == (9, 8, 9, 8, 9) + assert aligned.ticks == (9, 8, 9, 9, 8) + assert sum(across.ticks) == sum(aligned.ticks) def test_a_bar_of_whole_beats_reorganizes_the_groove_too(self) -> None: barred = self._groove(64, COMMON_TIME_BEAT, COMMON_TIME_BAR, 105) pattern_wide = self._groove(64, COMMON_TIME_BEAT, 64, 105) assert barred.ticks == ( - 9, 9, 9, 8, 9, 8, 9, 8, 9, 9, 9, 8, 9, 8, 9, 8, 9, 9, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 9, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 9, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, + 9, 9, 9, 8, 9, 8, 9, 8, 9, 9, 9, 8, 9, 8, 9, 8, ) # fmt: skip assert pattern_wide.ticks == ( - 9, 9, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 9, 9, 8, - 9, 8, 9, 8, 9, 8, 9, 8, 9, 9, 9, 8, 9, 8, 9, 8, - 9, 8, 9, 8, 9, 9, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, + 9, 9, 9, 8, 9, 8, 9, 8, 9, 9, 9, 8, 9, 8, 9, 8, + 9, 9, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, + 9, 9, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 9, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, ) # fmt: skip - assert barred.total_ticks == pattern_wide.total_ticks - - -class TestGrooveProperties(BaseTestSuite): - def test_total_ticks_sums_the_rows(self) -> None: - assert Groove(ticks=(5, 4, 4, 4)).total_ticks == 17 - - def test_mean_ticks_per_row_is_exact(self) -> None: - assert Groove(ticks=(5, 4, 4, 4)).mean_ticks_per_row == Fraction(17, 4) - - def test_a_varying_groove_is_not_uniform(self) -> None: - assert not Groove(ticks=(5, 4, 4, 4)).is_uniform - - def test_a_constant_groove_is_uniform(self) -> None: - assert Groove(ticks=(4, 4, 4, 4)).is_uniform - - def test_a_single_row_groove_is_uniform(self) -> None: - assert Groove(ticks=(4,)).is_uniform - - -class TestTheTicksASpanOfRowsLasts: - """A frame plays one whole pattern, so a span of rows reads the groove from its first row and goes - on from the next pattern's first row once it passes the last. - """ - - UNEVEN: Final[Groove] = Groove(ticks=(5, 4, 4, 3)) - - def test_a_span_within_the_pattern_adds_up_its_rows(self) -> None: - assert self.UNEVEN.ticks_across(1, 2) == 4 + 4 - - def test_a_span_past_the_last_row_goes_on_from_the_first(self) -> None: - assert self.UNEVEN.ticks_across(3, 3) == 3 + 5 + 4 - - def test_a_span_of_whole_patterns_lasts_their_ticks(self) -> None: - assert self.UNEVEN.ticks_across(2, 2 * len(self.UNEVEN.ticks)) == 2 * self.UNEVEN.total_ticks - - def test_an_empty_span_lasts_no_tick(self) -> None: - assert self.UNEVEN.ticks_across(2, 0) == 0 + assert sum(barred.ticks) == sum(pattern_wide.ticks) diff --git a/tests/unit/sampletones_core/timing/test_rate.py b/tests/unit/sampletones_core/timing/test_rate.py index 2b2cc410f..04a70e697 100644 --- a/tests/unit/sampletones_core/timing/test_rate.py +++ b/tests/unit/sampletones_core/timing/test_rate.py @@ -124,3 +124,22 @@ def test_settings_and_parameters_agree(self) -> None: speed=settings.speed, nes_frequency=settings.nes_frequency, ) + + +class TestBoundedRate(BaseTestSuite): + """An engine plays a row for at least its shortest and at most its longest length.""" + + @pytest.mark.parametrize( + ("ticks_per_row", "expected"), + ( + (Fraction(1, 4), Fraction(1)), + (Fraction(1), Fraction(1)), + (Fraction(36, 5), Fraction(36, 5)), + (Fraction(255), Fraction(255)), + (Fraction(2907, 4), Fraction(255)), + ), + ) + def test_a_rate_is_held_within_the_engine_range(self, ticks_per_row: Fraction, expected: Fraction) -> None: + bounded = RowRate(ticks_per_row=ticks_per_row).bounded(minimum_ticks=1, maximum_ticks=255) + + assert bounded == RowRate(ticks_per_row=expected) diff --git a/tests/unit/sampletones_core/timing/test_song.py b/tests/unit/sampletones_core/timing/test_song.py index 280e430f2..0602cb703 100644 --- a/tests/unit/sampletones_core/timing/test_song.py +++ b/tests/unit/sampletones_core/timing/test_song.py @@ -1,4 +1,6 @@ -from typing import Final +from fractions import Fraction +from itertools import chain +from typing import Final, Tuple import pytest @@ -6,17 +8,40 @@ from sampletones_core.performance import song_instructions from sampletones_core.project.project import Project from sampletones_core.project.settings import ProjectSettings -from sampletones_core.timing import SongTiming +from sampletones_core.timing import SONG_TICK_BOUNDS, Meter, RowRate, SongTiming, TickBounds +from tests.suite.base import BaseTestSuite +from tests.suite.groove import bar_line_drift, bar_rows, is_proportional ROWS_PER_PATTERN: Final[int] = 4 FRAMES: Final[int] = 3 +HALF_TICK: Final[Fraction] = Fraction(1, 2) +COMMON_TIME: Final[Meter] = Meter(rows=16, first_highlight=4, second_highlight=16) +TRACKER_BOUNDS: Final[TickBounds] = TickBounds(minimum=1, maximum=255) +FRAMES_WALKED: Final[int] = 40 + + +def _timing( + tempo: int, + speed: int, + nes_frequency: int, + meter: Meter, +) -> SongTiming: + return SongTiming( + rate=RowRate.from_parameters(tempo=tempo, speed=speed, nes_frequency=nes_frequency), + meter=meter, + bounds=SONG_TICK_BOUNDS, + ) + + +def _song_ticks(timing: SongTiming, frames: int) -> Tuple[int, ...]: + return tuple(chain.from_iterable(timing.groove(frame).ticks for frame in range(frames))) @pytest.fixture(name="project") def project_fixture() -> Project: project = Project.create( rows_per_pattern=ROWS_PER_PATTERN, - settings=ProjectSettings(tempo=150, speed=5, nes_frequency=60), + settings=ProjectSettings(tempo=125, speed=5, nes_frequency=60), ) for _ in range(FRAMES - project.song.order_length()): project.song.append_frame() @@ -25,17 +50,147 @@ def project_fixture() -> Project: class TestFrameTick: - """The tick an order frame starts on, read from the groove each frame lasts.""" + """A frame starts a bar, so it starts on the tick nearest its exact start.""" def test_the_first_frame_starts_the_song(self, project: Project) -> None: - assert SongTiming.from_project(project).frame_tick(0) == 0 + assert SongTiming.from_project(project, bounds=SONG_TICK_BOUNDS).frame_tick(0) == 0 + + def test_each_frame_starts_on_the_tick_nearest_its_exact_start(self) -> None: + """At 30/7 ticks a row, a 16-row pattern lasts 68 4/7 ticks, so frames start 69 or 68 ticks apart.""" + timing = _timing(210, 6, 60, COMMON_TIME) - def test_each_frame_starts_one_groove_after_the_one_before(self, project: Project) -> None: - timing = SongTiming.from_project(project) - groove = timing.groove().total_ticks - assert [timing.frame_tick(frame) for frame in range(FRAMES)] == [groove * frame for frame in range(FRAMES)] + assert [timing.frame_tick(frame) for frame in range(8)] == [0, 69, 137, 206, 274, 343, 411, 480] def test_the_frame_past_the_last_starts_where_the_walk_ends(self, project: Project) -> None: """The song's length and the loop point a frame names are read from one rule.""" walked = len(song_instructions(project)[ChannelName.PULSE1]) - assert SongTiming.from_project(project).frame_tick(project.song.order_length()) == walked + + assert ( + SongTiming.from_project(project, bounds=SONG_TICK_BOUNDS).frame_tick(project.song.order_length()) == walked + ) + + +class TestTheRemainderCarries: + """A frame lasts the ticks between its own start and the next frame's, so frames take turns at the surplus.""" + + def test_a_frame_after_a_longer_one_is_shorter(self) -> None: + timing = _timing(210, 6, 60, COMMON_TIME) + + assert timing.groove(0).ticks == (5, 4, 5, 4, 5, 4, 4, 4, 5, 4, 4, 4, 5, 4, 4, 4) + assert timing.groove(1).ticks == (5, 4, 4, 4, 5, 4, 4, 4, 5, 4, 4, 4, 5, 4, 4, 4) + + def test_the_grooves_repeat_once_the_exact_lengths_add_up_to_a_whole_tick(self) -> None: + """Seven patterns of 68 4/7 ticks come to 480, so the eighth frame plays the first one's groove.""" + timing = _timing(210, 6, 60, COMMON_TIME) + + assert [timing.groove(frame + 7) for frame in range(7)] == [timing.groove(frame) for frame in range(7)] + + def test_a_rate_of_whole_ticks_plays_every_frame_alike(self) -> None: + timing = _timing(150, 6, 60, COMMON_TIME) + + assert {timing.groove(frame) for frame in range(FRAMES_WALKED)} == {timing.groove(0)} + + +class TestRowLookups: + """Every answer about a row follows from its place in the song alone.""" + + @pytest.mark.parametrize("frame", (0, 1, 5, 13)) + def test_a_row_lasts_what_its_frame_groove_gives_it(self, frame: int) -> None: + timing = _timing(125, 6, 60, Meter(rows=12, first_highlight=3, second_highlight=6)) + groove = timing.groove(frame) + + assert [timing.row_ticks(frame, row) for row in range(12)] == list(groove.ticks) + + @pytest.mark.parametrize("frame", (0, 1, 5, 13)) + def test_a_row_starts_once_every_row_before_it_has_played(self, frame: int) -> None: + timing = _timing(125, 6, 60, Meter(rows=12, first_highlight=3, second_highlight=6)) + groove = timing.groove(frame) + + for row in range(12): + assert timing.tick_at(frame, row) == timing.frame_tick(frame) + sum(groove.ticks[:row]) + + +class TestTicksAcross: + """A span of rows reads each row at its own place, and the order comes round to its first frame.""" + + TIMING: Final[SongTiming] = _timing(210, 6, 60, COMMON_TIME) + + def test_a_span_within_a_frame_adds_up_its_rows(self) -> None: + assert self.TIMING.ticks_across(1, 2, 3, frames=4) == sum(self.TIMING.groove(1).ticks[2:5]) + + def test_a_span_into_the_next_frame_reads_that_frame(self) -> None: + expected = sum(self.TIMING.groove(0).ticks[14:]) + sum(self.TIMING.groove(1).ticks[:2]) + + assert self.TIMING.ticks_across(0, 14, 4, frames=4) == expected + + def test_a_span_past_the_song_goes_on_from_its_first_frame(self) -> None: + expected = sum(self.TIMING.groove(3).ticks[14:]) + sum(self.TIMING.groove(0).ticks[:2]) + + assert self.TIMING.ticks_across(3, 14, 4, frames=4) == expected + + def test_a_span_of_whole_passes_lasts_the_song_that_many_times(self) -> None: + assert self.TIMING.ticks_across(2, 5, 2 * 4 * 16, frames=4) == 2 * self.TIMING.frame_tick(4) + + def test_an_empty_span_lasts_no_tick(self) -> None: + assert self.TIMING.ticks_across(2, 5, 0, frames=4) == 0 + + +class TestTheBounds: + """A player holds every row within its own range, and the rate is held there first.""" + + def test_a_rate_below_one_tick_plays_every_row_for_one_tick(self) -> None: + """At tempo 255, speed 1 and 50 Hz a row asks for 25/51 ticks, so the song runs slower than set.""" + timing = _timing(255, 1, 50, COMMON_TIME) + + assert timing.groove(0).ticks == (1,) * 16 + assert timing.frame_tick(FRAMES) == FRAMES * 16 + + def test_a_rate_above_a_trackers_range_plays_every_row_at_its_longest(self) -> None: + timing = SongTiming( + rate=RowRate.from_parameters(tempo=32, speed=31, nes_frequency=300), + meter=COMMON_TIME, + bounds=TRACKER_BOUNDS, + ) + + assert timing.groove(1).ticks == (TRACKER_BOUNDS.maximum,) * 16 + + +SWEPT_METERS: Final[Tuple[Meter, ...]] = ( + COMMON_TIME, + Meter(rows=16, first_highlight=3, second_highlight=7), + Meter(rows=12, first_highlight=3, second_highlight=12), + Meter(rows=20, first_highlight=4, second_highlight=20), + Meter(rows=24, first_highlight=6, second_highlight=24), + Meter(rows=16, first_highlight=4, second_highlight=32), + Meter(rows=16, first_highlight=20, second_highlight=8), + Meter(rows=16, first_highlight=1, second_highlight=1), + Meter(rows=1, first_highlight=4, second_highlight=16), + Meter(rows=7, first_highlight=2, second_highlight=5), + Meter(rows=256, first_highlight=16, second_highlight=256), +) + + +def _meter_label(meter: Meter) -> str: + return f"{meter.rows}r_{meter.first_highlight}_{meter.second_highlight}" + + +class TestAWholeSongKeepsTheGroovesRules(BaseTestSuite): + """Over many frames, every bar line stays within half a tick of its exact start and every row in proportion.""" + + @pytest.mark.parametrize("meter", SWEPT_METERS, ids=_meter_label) + @pytest.mark.parametrize("tempo", (32, 97, 125, 133, 210, 251, 255)) + @pytest.mark.parametrize(("speed", "nes_frequency"), ((1, 60), (6, 60), (5, 50), (7, 41), (13, 15), (31, 300))) + def test_every_bar_line_and_every_row( + self, + meter: Meter, + tempo: int, + speed: int, + nes_frequency: int, + ) -> None: + timing = _timing(tempo, speed, nes_frequency, meter) + frames = max(1, FRAMES_WALKED * COMMON_TIME.rows // meter.rows) + ticks = _song_ticks(timing, frames) + + assert bar_line_drift(ticks, bar_rows(meter, frames), timing.exact_row_ticks) <= HALF_TICK + assert is_proportional(ticks, timing.exact_row_ticks) + assert sum(ticks) == timing.frame_tick(frames) diff --git a/tests/unit/sampletones_player/export/test_backend.py b/tests/unit/sampletones_player/export/test_backend.py index bf30a3fc1..c8a415343 100644 --- a/tests/unit/sampletones_player/export/test_backend.py +++ b/tests/unit/sampletones_player/export/test_backend.py @@ -17,7 +17,7 @@ from sampletones_core.exports.stage import ExportStage from sampletones_core.project.project import Project from sampletones_core.project.settings import ProjectSettings -from sampletones_core.timing import SongTiming +from sampletones_core.timing import SONG_TICK_BOUNDS, SongTiming from sampletones_player.builder import SONG_START from sampletones_player.compression.scheme import CompressionScheme from sampletones_player.driver.image import DriverImage @@ -306,7 +306,7 @@ def test_the_program_plays_the_song_the_project_arranges( project = drum_project() destination = tmp_path / FILENAME backend.write_project(destination, ProjectExport(project=project)) - expected = SongTiming.from_project(project).frame_tick(project.song.order_length()) + expected = SongTiming.from_project(project, bounds=SONG_TICK_BOUNDS).frame_tick(project.song.order_length()) assert song_word(destination.read_bytes(), TOTAL_TICKS_OFFSET) == expected def test_the_program_repeats_from_its_first_tick( @@ -357,7 +357,7 @@ def test_the_walk_counts_the_ticks_the_song_lasts( backend.write_project(tmp_path / FILENAME, ProjectExport(project=project), reporter) walked = [report for report in reporter.reports if report.stage == ExportStage.WALKING] - expected = SongTiming.from_project(project).frame_tick(project.song.order_length()) + expected = SongTiming.from_project(project, bounds=SONG_TICK_BOUNDS).frame_tick(project.song.order_length()) assert walked assert [report.total for report in walked] == [expected] * len(walked) assert walked[-1].completed == expected diff --git a/tests/unit/sampletones_player/test_builder.py b/tests/unit/sampletones_player/test_builder.py index beebcfa6f..a7c299faa 100644 --- a/tests/unit/sampletones_player/test_builder.py +++ b/tests/unit/sampletones_player/test_builder.py @@ -13,7 +13,7 @@ from sampletones_core.project.project import Project from sampletones_core.project.settings import ProjectSettings from sampletones_core.timers.utils import get_timer_table -from sampletones_core.timing import SongTiming +from sampletones_core.timing import SONG_TICK_BOUNDS, SongTiming from sampletones_player.builder import ( SONG_START, instructions_from_instruments, @@ -366,7 +366,9 @@ def test_the_song_lasts_the_ticks_the_projects_groove_gives_its_rows( ) -> None: project = drum_project() song = project_song(project, loop_tick=None) - assert song.ticks == SongTiming.from_project(project).frame_tick(project.song.order_length()) + assert song.ticks == SongTiming.from_project(project, bounds=SONG_TICK_BOUNDS).frame_tick( + project.song.order_length() + ) def test_the_schedule_follows_the_rate_the_project_states(self) -> None: song = project_song(drum_project(), loop_tick=None) diff --git a/tests/unit/sampletones_tools/console/__init__.py b/tests/unit/sampletones_tools/console/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_tools/console/files.py b/tests/unit/sampletones_tools/console/files.py new file mode 100644 index 000000000..75bd79b89 --- /dev/null +++ b/tests/unit/sampletones_tools/console/files.py @@ -0,0 +1,93 @@ +from dataclasses import dataclass, field +from typing import Dict, Final, Tuple + +from sampletones_core.formats.binary import BinaryWriter +from sampletones_player.specification.nsf import ( + BANKSWITCH_SIZE, + NSF2_LENGTH_SIZE, + NSF_MAGIC, + NSF_VERSION, + NTSC_PLAY_PERIOD_MICROSECONDS, + NTSC_REGION, + PAL_PLAY_PERIOD_MICROSECONDS, + PROGRAM_START, + STRING_FIELD_SIZE, +) +from sampletones_tools.console.cartridge import BANK_SIZE + +INIT: Final[int] = PROGRAM_START +PLAY: Final[int] = PROGRAM_START + 0x10 +LOADED_WHOLE: Final[Tuple[int, ...]] = (0,) * BANKSWITCH_SIZE +ONE_SONG: Final[int] = 1 + +STORE_ACCUMULATOR: Final[int] = 0x8D +STORE_INDEX: Final[int] = 0x8E +LOAD_IMMEDIATE: Final[int] = 0xA9 +LOAD_ABSOLUTE: Final[int] = 0xAD +JUMP: Final[int] = 0x4C +RETURN: Final[int] = 0x60 + + +def absolute(address: int) -> Tuple[int, int]: + """An operand address as the 6502 stores it, low byte first.""" + return address & 0xFF, address >> 8 + + +@dataclass(frozen=True) +class NSFFile: + """A small NSF file written for a case: a header and the program bytes placed at their addresses. + + Attributes: + load: The load address. + banks: The bank each slot starts with, all zero for a file loaded whole. + region: The region byte. + songs: How many songs the header names. + first_song: The song a player starts with, counted from 1. + program: The program's bytes, keyed by their offset within the image. + """ + + load: int = PROGRAM_START + banks: Tuple[int, ...] = LOADED_WHOLE + region: int = NTSC_REGION + songs: int = ONE_SONG + first_song: int = ONE_SONG + program: Dict[int, Tuple[int, ...]] = field(default_factory=dict) + + @property + def image(self) -> bytes: + """The bytes behind the header, each part of the program at its offset and zeros between.""" + size = max((offset + len(code) for offset, code in self.program.items()), default=0) + image = bytearray(size) + for offset, code in self.program.items(): + image[offset : offset + len(code)] = bytes(code) + + return bytes(image) + + @property + def data(self) -> bytes: + """The whole file, header first.""" + writer = BinaryWriter() + writer.write_bytes(NSF_MAGIC) + writer.write_uint8(NSF_VERSION) + writer.write_uint8(self.songs) + writer.write_uint8(self.first_song) + writer.write_uint16(self.load) + writer.write_uint16(INIT) + writer.write_uint16(PLAY) + for text in ("Title", "Artist", "Copyright"): + writer.write_fixed_string(text, STRING_FIELD_SIZE) + + writer.write_uint16(NTSC_PLAY_PERIOD_MICROSECONDS) + writer.write_bytes(bytes(self.banks)) + writer.write_uint16(PAL_PLAY_PERIOD_MICROSECONDS) + writer.write_uint8(self.region) + writer.write_uint8(0) + writer.write_uint8(0) + writer.write_bytes(bytes(NSF2_LENGTH_SIZE)) + writer.write_bytes(self.image) + return writer.data + + +def bank_offset(bank: int) -> int: + """Where a bank starts in an image loaded at the start of ``$8000``.""" + return bank * BANK_SIZE diff --git a/tests/unit/sampletones_tools/console/test_cartridge.py b/tests/unit/sampletones_tools/console/test_cartridge.py new file mode 100644 index 000000000..c6250cc78 --- /dev/null +++ b/tests/unit/sampletones_tools/console/test_cartridge.py @@ -0,0 +1,69 @@ +from typing import Final, Tuple + +from py65.memory import ObservableMemory + +from sampletones_player.specification.nsf import PROGRAM_START +from sampletones_tools.console.cartridge import BANK_SIZE, EMPTY_BYTE, FIRST_BANK_REGISTER, Cartridge +from sampletones_tools.console.header import NSFHeader +from tests.unit.sampletones_tools.console.files import NSFFile, bank_offset + +BANKS: Final[Tuple[int, ...]] = (0, 2, 0, 0, 0, 0, 0, 0) +MARKS: Final[Tuple[int, ...]] = (0x11, 0x22, 0x33) +SECOND_SLOT: Final[int] = PROGRAM_START + BANK_SIZE + + +def _banked_file(load: int = PROGRAM_START) -> NSFFile: + return NSFFile( + load=load, + banks=BANKS, + program={bank_offset(bank): (mark,) for bank, mark in enumerate(MARKS)}, + ) + + +def _cartridge(file: NSFFile) -> Cartridge: + return Cartridge(NSFHeader.read(file.data), file.image) + + +class TestBanks: + def test_a_bank_holds_its_four_kilobytes_of_the_image(self) -> None: + cartridge = _cartridge(_banked_file()) + + assert [cartridge.bank(bank)[0] for bank in range(len(MARKS))] == list(MARKS) + assert all(len(cartridge.bank(bank)) == BANK_SIZE for bank in range(len(MARKS))) + + def test_a_bank_past_the_image_reads_empty(self) -> None: + cartridge = _cartridge(_banked_file()) + + assert set(cartridge.bank(len(MARKS) + 1)) == {EMPTY_BYTE} + + def test_the_image_is_padded_by_where_the_load_address_falls_in_its_bank(self) -> None: + offset = 0x0100 + cartridge = _cartridge(_banked_file(load=PROGRAM_START + offset)) + + assert cartridge.bank(0)[offset] == MARKS[0] + + +class TestMounting: + def test_a_file_loaded_whole_lands_at_its_load_address(self) -> None: + load = PROGRAM_START + 0x0100 + file = NSFFile(load=load, program={0: (0xAB, 0xCD)}) + memory = ObservableMemory() + + _cartridge(file).mount(memory) + + assert (memory[load], memory[load + 1]) == (0xAB, 0xCD) + + def test_each_slot_starts_with_the_bank_the_header_names(self) -> None: + memory = ObservableMemory() + + _cartridge(_banked_file()).mount(memory) + + assert (memory[PROGRAM_START], memory[SECOND_SLOT]) == (MARKS[0], MARKS[2]) + + def test_a_write_to_a_slots_register_maps_the_bank_written(self) -> None: + memory = ObservableMemory() + _cartridge(_banked_file()).mount(memory) + + memory[FIRST_BANK_REGISTER + 1] = 1 + + assert memory[SECOND_SLOT] == MARKS[1] diff --git a/tests/unit/sampletones_tools/console/test_header.py b/tests/unit/sampletones_tools/console/test_header.py new file mode 100644 index 000000000..c974445d5 --- /dev/null +++ b/tests/unit/sampletones_tools/console/test_header.py @@ -0,0 +1,78 @@ +from dataclasses import dataclass +from typing import Final, Tuple + +import pytest + +from sampletones_player.specification.nsf import ( + DUAL_REGION_FLAG, + NTSC_PLAY_PERIOD_MICROSECONDS, + NTSC_REGION, + PAL_PLAY_PERIOD_MICROSECONDS, + PAL_REGION_FLAG, + PROGRAM_START, +) +from sampletones_shared.exceptions.validation import TruncatedDataError +from sampletones_tools.console.errors import NotAnNSFError +from sampletones_tools.console.header import NTSC_MACHINE, PAL_MACHINE, NSFHeader +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseAutolabelTestCase +from tests.unit.sampletones_tools.console.files import INIT, LOADED_WHOLE, PLAY, NSFFile + +BANKS: Final[Tuple[int, ...]] = (0, 1, 2, 3, 4, 5, 6, 7) +LOAD: Final[int] = PROGRAM_START + 0x0100 + + +class TestReadingAHeader: + def test_every_field_a_player_reads_comes_back(self) -> None: + header = NSFHeader.read(NSFFile(load=LOAD, banks=BANKS, songs=3, first_song=2).data) + + assert header == NSFHeader( + songs=3, + first_song=2, + load=LOAD, + init=INIT, + play=PLAY, + ntsc_period=NTSC_PLAY_PERIOD_MICROSECONDS, + banks=BANKS, + pal_period=PAL_PLAY_PERIOD_MICROSECONDS, + region=NTSC_REGION, + ) + + def test_the_first_song_is_handed_to_init_counted_from_zero(self) -> None: + assert NSFHeader.read(NSFFile(songs=3, first_song=2).data).first_song_index == 1 + + def test_a_header_naming_a_bank_is_banked(self) -> None: + assert NSFHeader.read(NSFFile(banks=BANKS).data).banked + assert not NSFHeader.read(NSFFile(banks=LOADED_WHOLE).data).banked + + def test_data_without_the_signature_is_refused(self) -> None: + with pytest.raises(NotAnNSFError): + NSFHeader.read(b"NESM\x00" + NSFFile().data[5:]) + + def test_data_ending_inside_the_header_is_refused(self) -> None: + with pytest.raises(TruncatedDataError): + NSFHeader.read(NSFFile().data[:40]) + + +class TestTheMachine(BaseTestSuite): + """The init routine is told PAL for a file made for PAL alone, and NTSC for any other.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseAutolabelTestCase): + region: int + expected: int + + @property + def label(self) -> str: + return f"region-{self.region}" + + test_cases: Tuple[TestCase, ...] = ( + TestCase(region=NTSC_REGION, expected=NTSC_MACHINE), + TestCase(region=PAL_REGION_FLAG, expected=PAL_MACHINE), + TestCase(region=PAL_REGION_FLAG | DUAL_REGION_FLAG, expected=NTSC_MACHINE), + TestCase(region=DUAL_REGION_FLAG, expected=NTSC_MACHINE), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_region_names_the_machine(self, test_case: TestCase) -> None: + assert NSFHeader.read(NSFFile(region=test_case.region).data).machine == test_case.expected diff --git a/tests/unit/sampletones_tools/console/test_machine.py b/tests/unit/sampletones_tools/console/test_machine.py new file mode 100644 index 000000000..b99fa5114 --- /dev/null +++ b/tests/unit/sampletones_tools/console/test_machine.py @@ -0,0 +1,137 @@ +from typing import Final, Tuple + +import pytest + +from sampletones_player.specification.nsf import PAL_REGION_FLAG, PROGRAM_START +from sampletones_player.specification.registers import APU_STATUS, PULSE1_CONTROL +from sampletones_tools.console.cartridge import BANK_SIZE, FIRST_BANK_REGISTER +from sampletones_tools.console.errors import RoutineOverrunError +from sampletones_tools.console.header import PAL_MACHINE +from sampletones_tools.console.machine import Console +from sampletones_tools.player.trace.write import RegisterWrite +from tests.unit.sampletones_tools.console.files import ( + INIT, + JUMP, + LOAD_ABSOLUTE, + LOAD_IMMEDIATE, + PLAY, + RETURN, + STORE_ACCUMULATOR, + STORE_INDEX, + NSFFile, + absolute, + bank_offset, +) + +SONG_REGISTER: Final[int] = 0x4002 +MACHINE_REGISTER: Final[int] = 0x4003 +CHANNELS_ON: Final[int] = 0x0F +PLAYED: Final[int] = 0x3F +SECOND_SLOT: Final[int] = PROGRAM_START + BANK_SIZE +BANK_MARKS: Final[Tuple[int, int]] = (0x77, 0x55) +STEP_BUDGET: Final[int] = 1000 +ENDLESS_BUDGET: Final[int] = 50 + +INIT_PROGRAM: Final[Tuple[int, ...]] = ( + STORE_ACCUMULATOR, + *absolute(SONG_REGISTER), + STORE_INDEX, + *absolute(MACHINE_REGISTER), + LOAD_IMMEDIATE, + CHANNELS_ON, + STORE_ACCUMULATOR, + *absolute(APU_STATUS), + RETURN, +) +PLAY_PROGRAM: Final[Tuple[int, ...]] = ( + LOAD_IMMEDIATE, + PLAYED, + STORE_ACCUMULATOR, + *absolute(PULSE1_CONTROL), + RETURN, +) +SWITCHING_INIT: Final[Tuple[int, ...]] = ( + LOAD_IMMEDIATE, + 1, + STORE_ACCUMULATOR, + *absolute(FIRST_BANK_REGISTER + 1), + RETURN, +) +READING_PLAY: Final[Tuple[int, ...]] = ( + LOAD_ABSOLUTE, + *absolute(SECOND_SLOT), + STORE_ACCUMULATOR, + *absolute(PULSE1_CONTROL), + RETURN, +) +ENDLESS_INIT: Final[Tuple[int, ...]] = (JUMP, *absolute(INIT)) +INIT_WRITES: Final[Tuple[RegisterWrite, ...]] = ( + RegisterWrite(SONG_REGISTER, 1), + RegisterWrite(MACHINE_REGISTER, PAL_MACHINE), + RegisterWrite(APU_STATUS, CHANNELS_ON), +) + + +def _console(file: NSFFile) -> Console: + return Console(file.data, step_budget=STEP_BUDGET) + + +@pytest.fixture(name="console") +def console_fixture() -> Console: + """A PAL file holding three songs and starting on the second, whose routines write what they are given.""" + return _console( + NSFFile( + region=PAL_REGION_FLAG, + songs=3, + first_song=2, + program={INIT - PROGRAM_START: INIT_PROGRAM, PLAY - PROGRAM_START: PLAY_PROGRAM}, + ) + ) + + +@pytest.fixture(name="banked") +def banked_fixture() -> Console: + """A banked file whose second slot starts on bank 2, which its init routine switches to bank 1.""" + return _console( + NSFFile( + banks=(0, 2, 0, 0, 0, 0, 0, 0), + program={ + INIT - PROGRAM_START: SWITCHING_INIT, + PLAY - PROGRAM_START: READING_PLAY, + bank_offset(1): (BANK_MARKS[0],), + bank_offset(2): (BANK_MARKS[1],), + }, + ) + ) + + +class TestRoutines: + def test_init_is_handed_the_first_song_and_the_machine(self, console: Console) -> None: + assert console.initialize() == INIT_WRITES + + def test_a_play_call_answers_with_its_own_writes_alone(self, console: Console) -> None: + console.initialize() + + assert console.play() == (RegisterWrite(PULSE1_CONTROL, PLAYED),) + + def test_a_trace_holds_the_initialization_and_every_play_call(self, console: Console) -> None: + trace = console.trace(3) + + assert trace.initialization == INIT_WRITES + assert trace.play_calls == ((RegisterWrite(PULSE1_CONTROL, PLAYED),),) * 3 + + def test_a_routine_that_never_returns_is_stopped(self) -> None: + console = Console(NSFFile(program={INIT - PROGRAM_START: ENDLESS_INIT}).data, step_budget=ENDLESS_BUDGET) + + with pytest.raises(RoutineOverrunError): + console.initialize() + + +class TestBankSwitching: + def test_a_slot_plays_the_bank_the_header_names_until_the_program_switches_it(self, banked: Console) -> None: + assert banked.play() == (RegisterWrite(PULSE1_CONTROL, BANK_MARKS[1]),) + + def test_a_switch_maps_the_bank_the_program_names(self, banked: Console) -> None: + banked.initialize() + + assert banked.play() == (RegisterWrite(PULSE1_CONTROL, BANK_MARKS[0]),) diff --git a/tests/unit/sampletones_tools/tracker_playback/targets/bitphase/test_target.py b/tests/unit/sampletones_tools/tracker_playback/targets/bitphase/test_target.py index 1ed0a0fab..5a67ecb87 100644 --- a/tests/unit/sampletones_tools/tracker_playback/targets/bitphase/test_target.py +++ b/tests/unit/sampletones_tools/tracker_playback/targets/bitphase/test_target.py @@ -77,4 +77,4 @@ def run(command: Sequence[str], **options: Any) -> None: [str(playback.document), str(documents / f"{NAME}{EXT_FILE_JSON}")], ] assert playback.trace.ticks == 1 - assert (playback.skipped_rows, playback.truncation) == (0, None) + assert (playback.skipped_rows, playback.truncation) == ((), None) diff --git a/tests/unit/sampletones_tools/tracker_playback/targets/famitracker/__init__.py b/tests/unit/sampletones_tools/tracker_playback/targets/famitracker/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_tools/tracker_playback/targets/famitracker/program/__init__.py b/tests/unit/sampletones_tools/tracker_playback/targets/famitracker/program/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_tools/tracker_playback/targets/famitracker/program/test_factory.py b/tests/unit/sampletones_tools/tracker_playback/targets/famitracker/program/test_factory.py new file mode 100644 index 000000000..8b02f4ae8 --- /dev/null +++ b/tests/unit/sampletones_tools/tracker_playback/targets/famitracker/program/test_factory.py @@ -0,0 +1,47 @@ +from pathlib import Path + +import pytest + +from sampletones_shared.utils.system.system import System +from sampletones_tools.tracker_playback.targets.famitracker.errors import FamiTrackerError +from sampletones_tools.tracker_playback.targets.famitracker.program import wine +from sampletones_tools.tracker_playback.targets.famitracker.program.factory import located_program +from sampletones_tools.tracker_playback.targets.famitracker.program.native import NativeProgram +from sampletones_tools.tracker_playback.targets.famitracker.program.wine import WineProgram + + +@pytest.fixture(name="executable") +def executable_fixture(tmp_path: Path) -> Path: + path = tmp_path / "FamiTracker.exe" + path.write_bytes(b"MZ") + return path + + +class TestLocatedProgram: + def test_windows_runs_famitracker_directly(self, monkeypatch: pytest.MonkeyPatch, executable: Path) -> None: + monkeypatch.setattr(System, "current", classmethod(lambda cls: System.WINDOWS)) + + assert located_program(executable) == NativeProgram(executable=executable) + + @pytest.mark.parametrize("system", (System.LINUX, System.MACOS)) + def test_linux_and_macos_run_famitracker_through_wine( + self, + monkeypatch: pytest.MonkeyPatch, + executable: Path, + system: System, + ) -> None: + monkeypatch.setattr(System, "current", classmethod(lambda cls: system)) + monkeypatch.setattr(wine, "locate_program", lambda program: Path("/usr/bin") / program) + + assert located_program(executable) == WineProgram(wine=Path("/usr/bin/wine"), executable=executable) + + def test_a_missing_program_is_refused(self, tmp_path: Path) -> None: + with pytest.raises(FamiTrackerError, match="FamiTracker.exe"): + located_program(tmp_path / "FamiTracker.exe") + + def test_a_launcher_script_is_refused(self, tmp_path: Path) -> None: + launcher = tmp_path / "famitracker" + launcher.write_text("#!/bin/bash\nexec wine FamiTracker.exe", encoding="utf-8") + + with pytest.raises(FamiTrackerError, match="FamiTracker.exe itself"): + located_program(launcher) diff --git a/tests/unit/sampletones_tools/tracker_playback/targets/famitracker/program/test_native.py b/tests/unit/sampletones_tools/tracker_playback/targets/famitracker/program/test_native.py new file mode 100644 index 000000000..8c001382e --- /dev/null +++ b/tests/unit/sampletones_tools/tracker_playback/targets/famitracker/program/test_native.py @@ -0,0 +1,18 @@ +import os +from pathlib import Path + +from sampletones_tools.tracker_playback.targets.famitracker.program.native import NativeProgram +from sampletones_tools.tracker_playback.targets.famitracker.program.protocol import EXPORT_SWITCH + + +class TestNativeProgram: + def test_famitracker_is_handed_every_path_absolute(self, tmp_path: Path) -> None: + program = NativeProgram(executable=tmp_path / "FamiTracker.exe") + + command = program.export_command(Path("song.ftm"), Path("song.nsf"), Path("song.log")) + + assert command[2] == EXPORT_SWITCH + assert all(Path(argument).is_absolute() for index, argument in enumerate(command) if index != 2) + + def test_the_export_runs_in_the_checks_own_environment(self, tmp_path: Path) -> None: + assert NativeProgram(executable=tmp_path / "FamiTracker.exe").environment() == dict(os.environ) diff --git a/tests/unit/sampletones_tools/tracker_playback/targets/famitracker/program/test_wine.py b/tests/unit/sampletones_tools/tracker_playback/targets/famitracker/program/test_wine.py new file mode 100644 index 000000000..e7b01dbc1 --- /dev/null +++ b/tests/unit/sampletones_tools/tracker_playback/targets/famitracker/program/test_wine.py @@ -0,0 +1,119 @@ +import subprocess +from pathlib import Path +from typing import Any, Final, List + +import pytest + +from sampletones_shared.utils.system.system import System +from sampletones_tools.tracker_playback.targets.famitracker.errors import FamiTrackerError +from sampletones_tools.tracker_playback.targets.famitracker.program import wine +from sampletones_tools.tracker_playback.targets.famitracker.program.protocol import EXPORT_SWITCH +from sampletones_tools.tracker_playback.targets.famitracker.program.wine import ( + DISPLAY_VARIABLES, + INSTALL_HINTS, + WINE_DEBUG, + WINE_DEBUG_SILENT, + WINEPATH, + WineProgram, +) + +WINE: Final[Path] = Path("/usr/bin/wine") +EXECUTABLE: Final[Path] = Path("/apps/FamiTracker.exe") + + +def on_drive_z(path: str) -> str: + """A path as a default Wine prefix maps it: the file system's root is drive Z.""" + return "Z:" + path.replace("/", "\\") + + +class MappingWine: + """Wine's ``winepath`` standing in: it maps each path onto drive Z and records the commands it ran.""" + + def __init__(self) -> None: + self.commands: List[List[str]] = [] + + def run(self, command: List[str], **options: Any) -> subprocess.CompletedProcess[str]: + self.commands.append(command) + mapped = "".join(f"{on_drive_z(path)}\n" for path in command[3:]) + return subprocess.CompletedProcess(command, 0, stdout=mapped, stderr="") + + +@pytest.fixture(name="mapping") +def mapping_fixture(monkeypatch: pytest.MonkeyPatch) -> MappingWine: + mapping = MappingWine() + monkeypatch.setattr(wine.subprocess, "run", mapping.run) + return mapping + + +class TestLocating: + def test_the_program_takes_the_wine_this_system_has(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(wine, "locate_program", lambda program: Path("/opt/bin") / program) + + assert WineProgram.located(EXECUTABLE) == WineProgram(wine=Path("/opt/bin/wine"), executable=EXECUTABLE) + + def test_an_absent_wine_names_how_this_system_installs_it(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(wine, "locate_program", lambda program: None) + monkeypatch.setattr(System, "current", classmethod(lambda cls: System.LINUX)) + + with pytest.raises(FamiTrackerError, match=INSTALL_HINTS[System.LINUX]): + WineProgram.located(EXECUTABLE) + + +class TestTheExportCommand: + def test_famitracker_is_handed_every_path_as_wine_maps_it(self, mapping: MappingWine, tmp_path: Path) -> None: + program = WineProgram(wine=WINE, executable=EXECUTABLE) + + module, nsf, log = (tmp_path / name for name in ("song.ftm", "song.nsf", "song.log")) + + command = program.export_command(module, nsf, log) + + assert command == [ + str(WINE), + str(EXECUTABLE), + on_drive_z(str(module.resolve())), + EXPORT_SWITCH, + on_drive_z(str(nsf.resolve())), + on_drive_z(str(log.resolve())), + ] + + def test_wine_maps_every_path_in_one_call(self, mapping: MappingWine, tmp_path: Path) -> None: + WineProgram(wine=WINE, executable=EXECUTABLE).export_command(tmp_path / "a", tmp_path / "b", tmp_path / "c") + + (command,) = mapping.commands + assert command[:3] == [str(WINE), WINEPATH, "-w"] + + def test_a_failed_mapping_is_reported(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + def run(command: List[str], **options: Any) -> subprocess.CompletedProcess[str]: + raise subprocess.CalledProcessError(1, command, stderr="no prefix") + + monkeypatch.setattr(wine.subprocess, "run", run) + + with pytest.raises(FamiTrackerError, match="no prefix"): + WineProgram(wine=WINE, executable=EXECUTABLE).windows_paths((tmp_path,)) + + def test_a_mapping_missing_a_path_is_reported(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + def run(command: List[str], **options: Any) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess(command, 0, stdout="Z:\\one\n", stderr="") + + monkeypatch.setattr(wine.subprocess, "run", run) + + with pytest.raises(FamiTrackerError): + WineProgram(wine=WINE, executable=EXECUTABLE).windows_paths((tmp_path / "a", tmp_path / "b")) + + +class TestTheEnvironment: + def test_the_export_runs_with_no_display_and_wine_silenced(self, monkeypatch: pytest.MonkeyPatch) -> None: + for variable in DISPLAY_VARIABLES: + monkeypatch.setenv(variable, ":1") + + environment = WineProgram(wine=WINE, executable=EXECUTABLE).environment() + + assert not set(DISPLAY_VARIABLES) & set(environment) + assert environment[WINE_DEBUG] == WINE_DEBUG_SILENT + + def test_the_rest_of_the_environment_carries_over(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("WINEPREFIX", "/home/someone/.wine-famitracker") + + environment = WineProgram(wine=WINE, executable=EXECUTABLE).environment() + + assert environment["WINEPREFIX"] == "/home/someone/.wine-famitracker" diff --git a/tests/unit/sampletones_tools/tracker_playback/targets/famitracker/programs.py b/tests/unit/sampletones_tools/tracker_playback/targets/famitracker/programs.py new file mode 100644 index 000000000..d73ed7b26 --- /dev/null +++ b/tests/unit/sampletones_tools/tracker_playback/targets/famitracker/programs.py @@ -0,0 +1,73 @@ +import os +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Final, List, Optional, Tuple + +from sampletones_player.specification.nsf import PROGRAM_START +from sampletones_player.specification.registers import DMC_DIRECT_LOAD +from tests.unit.sampletones_tools.console.files import ( + INIT, + PLAY, + RETURN, + STORE_ACCUMULATOR, + NSFFile, + absolute, +) + +LOG_TEXT: Final[str] = "Opened the module.\nNSF export complete." +COUNTER: Final[int] = 0x00 +LOAD_ZERO_PAGE: Final[int] = 0xA5 +INCREMENT_ZERO_PAGE: Final[int] = 0xE6 +EXPORT_SCRIPT: Final[str] = """ +import shutil, sys +source, nsf, log, log_text = sys.argv[1:] +if source: + shutil.copyfile(source, nsf) +with open(log, "w", encoding="utf-8") as stream: + stream.write(log_text) +""" +MARKING_PLAY: Final[Tuple[int, ...]] = ( + LOAD_ZERO_PAGE, + COUNTER, + STORE_ACCUMULATOR, + *absolute(DMC_DIRECT_LOAD), + INCREMENT_ZERO_PAGE, + COUNTER, + RETURN, +) + + +def marking_nsf() -> bytes: + """An NSF whose every play call starts a row: it writes a marker carrying how many calls came before.""" + return NSFFile(program={INIT - PROGRAM_START: (RETURN,), PLAY - PROGRAM_START: MARKING_PLAY}).data + + +@dataclass(frozen=True) +class StandInProgram: + """A program standing in for FamiTracker: its export copies a prepared NSF into place and writes a log. + + It records every module it is asked to export, so a case reads what reached it. + + Attributes: + executable: The program file the report names. + source: The NSF the export leaves, or ``None`` for an export that writes none. + exported: The modules the export was asked for, in order. + """ + + executable: Path + source: Optional[Path] + exported: List[bytes] + + def export_command( + self, + module: Path, + nsf: Path, + log: Path, + ) -> List[str]: + self.exported.append(module.read_bytes()) + source = str(self.source) if self.source is not None else "" + return [sys.executable, "-c", EXPORT_SCRIPT, source, str(nsf), str(log), LOG_TEXT] + + def environment(self) -> Dict[str, str]: + return dict(os.environ) diff --git a/tests/unit/sampletones_tools/tracker_playback/targets/famitracker/test_export.py b/tests/unit/sampletones_tools/tracker_playback/targets/famitracker/test_export.py new file mode 100644 index 000000000..96e446139 --- /dev/null +++ b/tests/unit/sampletones_tools/tracker_playback/targets/famitracker/test_export.py @@ -0,0 +1,78 @@ +import subprocess +from pathlib import Path +from typing import Any, List + +import pytest + +from sampletones_tools.tracker_playback.targets.famitracker import export +from sampletones_tools.tracker_playback.targets.famitracker.errors import FamiTrackerError +from sampletones_tools.tracker_playback.targets.famitracker.export import NO_LOG, export_nsf, log_text +from tests.unit.sampletones_tools.tracker_playback.targets.famitracker.programs import ( + LOG_TEXT, + StandInProgram, + marking_nsf, +) + + +@pytest.fixture(name="source") +def source_fixture(tmp_path: Path) -> Path: + path = tmp_path / "prepared.nsf" + path.write_bytes(marking_nsf()) + return path + + +class TestExportNSF: + def test_the_nsf_the_export_writes_is_left_in_place(self, tmp_path: Path, source: Path) -> None: + program = StandInProgram(executable=tmp_path / "FamiTracker.exe", source=source, exported=[]) + module = tmp_path / "song.ftm" + module.write_bytes(b"module") + + export_nsf(program, module, tmp_path / "song.nsf", tmp_path / "song.log") + + assert (tmp_path / "song.nsf").read_bytes() == source.read_bytes() + assert program.exported == [b"module"] + + def test_an_export_writing_no_nsf_is_reported_with_its_log(self, tmp_path: Path) -> None: + program = StandInProgram(executable=tmp_path / "FamiTracker.exe", source=None, exported=[]) + module = tmp_path / "song.ftm" + module.write_bytes(b"module") + + with pytest.raises(FamiTrackerError, match="NSF export complete"): + export_nsf(program, module, tmp_path / "song.nsf", tmp_path / "song.log") + + def test_an_nsf_left_by_an_earlier_run_counts_for_nothing(self, tmp_path: Path) -> None: + program = StandInProgram(executable=tmp_path / "FamiTracker.exe", source=None, exported=[]) + module = tmp_path / "song.ftm" + module.write_bytes(b"module") + (tmp_path / "song.nsf").write_bytes(b"stale") + + with pytest.raises(FamiTrackerError): + export_nsf(program, module, tmp_path / "song.nsf", tmp_path / "song.log") + + assert not (tmp_path / "song.nsf").exists() + + def test_an_export_running_past_its_time_is_reported( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + ) -> None: + def run(*arguments: Any, **options: Any) -> List[str]: + raise subprocess.TimeoutExpired(cmd="FamiTracker.exe", timeout=options["timeout"]) + + monkeypatch.setattr(export.subprocess, "run", run) + program = StandInProgram(executable=tmp_path / "FamiTracker.exe", source=None, exported=[]) + module = tmp_path / "song.ftm" + module.write_bytes(b"module") + + with pytest.raises(FamiTrackerError, match="seconds"): + export_nsf(program, module, tmp_path / "song.nsf", tmp_path / "song.log") + + +class TestLogText: + def test_the_log_is_read_as_written(self, tmp_path: Path) -> None: + (tmp_path / "song.log").write_text(LOG_TEXT, encoding="utf-8") + + assert log_text(tmp_path / "song.log") == LOG_TEXT + + def test_a_missing_log_says_so(self, tmp_path: Path) -> None: + assert log_text(tmp_path / "song.log") == NO_LOG diff --git a/tests/unit/sampletones_tools/tracker_playback/targets/famitracker/test_markers.py b/tests/unit/sampletones_tools/tracker_playback/targets/famitracker/test_markers.py new file mode 100644 index 000000000..543164b25 --- /dev/null +++ b/tests/unit/sampletones_tools/tracker_playback/targets/famitracker/test_markers.py @@ -0,0 +1,73 @@ +from typing import Final, List + +import pytest + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.formats.famitracker.builder import build_module +from sampletones_core.formats.famitracker.model.module import FamiTrackerModule +from sampletones_core.formats.famitracker.specification.channels import ChannelId +from sampletones_core.formats.famitracker.specification.patterns import MAX_DAC_LEVEL, EffectId +from sampletones_core.project.settings import ProjectSettings +from sampletones_tools.tracker_playback.targets.famitracker.markers import MARKER_LEVELS, marked_module, row_marker +from tests.suite.performance import make_pulse_reconstruction, place_instrument, project_with_sample + +ROWS: Final[int] = 4 +FRAMES: Final[int] = 3 + + +@pytest.fixture(name="module") +def module_fixture() -> FamiTrackerModule: + """A module of three frames of four rows, a note on pulse 1 in the first.""" + project, sample = project_with_sample( + make_pulse_reconstruction(count=4), + rows_per_pattern=ROWS, + settings=ProjectSettings(tempo=150, speed=6, nes_frequency=60), + ) + place_instrument(project, channel_name=ChannelName.PULSE1, row_index=0, sample=sample, volume=15) + project.song.order.extend(dict(project.song.order[0]) for _ in range(FRAMES - 1)) + return build_module(project).document + + +class TestRowMarker: + def test_a_marker_is_the_rows_place_wrapped_into_the_marker_levels(self) -> None: + last = MARKER_LEVELS - 1 + + assert [row_marker(index) for index in (0, 1, last, last + 1)] == [0, 1, last, 0] + + def test_every_marker_is_a_level_the_dmc_takes(self) -> None: + assert MARKER_LEVELS - 1 <= MAX_DAC_LEVEL + + def test_two_rows_in_a_row_carry_different_markers(self) -> None: + assert all(row_marker(index) != row_marker(index + 1) for index in range(1000)) + + +class TestMarkedModule: + def test_every_frame_plays_a_dpcm_pattern_of_its_own(self, module: FamiTrackerModule) -> None: + marked = marked_module(module) + + assert [entries[ChannelId.DPCM] for entries in marked.track.order] == list(range(FRAMES)) + + def test_every_row_loads_its_place_in_the_song(self, module: FamiTrackerModule) -> None: + marked = marked_module(module) + levels: List[int] = [] + for frame in range(FRAMES): + (pattern,) = ( + pattern + for pattern in marked.track.patterns + if pattern.channel == ChannelId.DPCM and pattern.index == frame + ) + levels.extend(level for cell in pattern.rows for effect, level in cell.effects if effect == EffectId.DAC) + + assert levels == [row_marker(index) for index in range(FRAMES * ROWS)] + + def test_the_other_channels_stay_as_the_export_built_them(self, module: FamiTrackerModule) -> None: + marked = marked_module(module) + + def tonal(document: FamiTrackerModule) -> object: + return ( + [entries[: ChannelId.DPCM] for entries in document.track.order], + [pattern for pattern in document.track.patterns if pattern.channel != ChannelId.DPCM], + document.instruments, + ) + + assert tonal(marked) == tonal(module) diff --git a/tests/unit/sampletones_tools/tracker_playback/targets/famitracker/test_target.py b/tests/unit/sampletones_tools/tracker_playback/targets/famitracker/test_target.py new file mode 100644 index 000000000..6617445bb --- /dev/null +++ b/tests/unit/sampletones_tools/tracker_playback/targets/famitracker/test_target.py @@ -0,0 +1,135 @@ +from pathlib import Path +from typing import Final + +import pytest + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.formats.famitracker.builder import build_module +from sampletones_core.formats.famitracker.module import module_to_ftm_bytes +from sampletones_core.project.project import Project +from sampletones_core.project.settings import ProjectSettings +from sampletones_shared.paths.extensions import EXT_FILE_JSON, EXT_FILE_MODULE, EXT_FILE_NSF +from sampletones_shared.utils.system.system import System +from sampletones_tools.tracker_playback.targets.famitracker import target as target_module +from sampletones_tools.tracker_playback.targets.famitracker.errors import FamiTrackerError +from sampletones_tools.tracker_playback.targets.famitracker.markers import marked_module +from sampletones_tools.tracker_playback.targets.famitracker.program import wine +from sampletones_tools.tracker_playback.targets.famitracker.program.wine import WineProgram +from sampletones_tools.tracker_playback.targets.famitracker.target import TITLE, FamiTrackerTarget +from sampletones_tools.tracker_playback.targets.famitracker.trace import DriverTrace +from tests.suite.performance import make_pulse_reconstruction, place_instrument, project_with_sample +from tests.unit.sampletones_tools.tracker_playback.targets.famitracker.programs import StandInProgram, marking_nsf + +NAME: Final[str] = "tone" +ROWS: Final[int] = 2 + + +@pytest.fixture(name="project") +def project_fixture() -> Project: + """A song of one frame of two rows, a note on pulse 1.""" + project, sample = project_with_sample( + make_pulse_reconstruction(count=4), + rows_per_pattern=ROWS, + settings=ProjectSettings(tempo=150, speed=6, nes_frequency=60), + ) + place_instrument(project, channel_name=ChannelName.PULSE1, row_index=0, sample=sample, volume=15) + return project + + +def _program(tmp_path: Path, nsf: bytes) -> StandInProgram: + source = tmp_path / "prepared.nsf" + source.write_bytes(nsf) + return StandInProgram(executable=tmp_path / "FamiTracker.exe", source=source, exported=[]) + + +@pytest.fixture(name="documents") +def documents_fixture(tmp_path: Path) -> Path: + path = tmp_path / "documents" + path.mkdir() + return path + + +class TestFamiTrackerTarget: + def test_the_report_names_famitracker_and_the_program_that_exported(self, tmp_path: Path) -> None: + target = FamiTrackerTarget(program=_program(tmp_path, marking_nsf())) + + assert target.title == TITLE + assert str(tmp_path / "FamiTracker.exe") in target.player + + def test_the_target_runs_famitracker_the_way_this_system_does( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + ) -> None: + executable = tmp_path / "FamiTracker.exe" + executable.write_bytes(b"MZ") + monkeypatch.setattr(System, "current", classmethod(lambda cls: System.LINUX)) + monkeypatch.setattr(wine, "locate_program", lambda program: Path("/usr/bin") / program) + + assert FamiTrackerTarget.located(executable).program == WineProgram( + wine=Path("/usr/bin/wine"), + executable=executable, + ) + + +class TestPlayingAProject: + def test_the_module_is_kept_as_the_export_built_it( + self, + tmp_path: Path, + documents: Path, + project: Project, + ) -> None: + playback = FamiTrackerTarget(program=_program(tmp_path, marking_nsf())).play(project, documents, NAME) + + assert playback.document == documents / f"{NAME}{EXT_FILE_MODULE}" + assert playback.document.read_bytes() == module_to_ftm_bytes(build_module(project).document) + + def test_famitracker_exports_the_module_with_its_rows_marked( + self, + tmp_path: Path, + documents: Path, + project: Project, + ) -> None: + program = _program(tmp_path, marking_nsf()) + + FamiTrackerTarget(program=program).play(project, documents, NAME) + + assert program.exported == [module_to_ftm_bytes(marked_module(build_module(project).document))] + + def test_the_nsf_and_every_write_its_driver_made_are_kept_beside_the_module( + self, + tmp_path: Path, + documents: Path, + project: Project, + ) -> None: + playback = FamiTrackerTarget(program=_program(tmp_path, marking_nsf())).play(project, documents, NAME) + + recorded = DriverTrace.model_validate_json((documents / f"{NAME}{EXT_FILE_JSON}").read_text(encoding="utf-8")) + assert (documents / f"{NAME}{EXT_FILE_NSF}").read_bytes() == marking_nsf() + assert len(recorded.ticks) == playback.trace.ticks == ROWS + + def test_an_nsf_that_fails_to_play_is_reported( + self, + tmp_path: Path, + documents: Path, + project: Project, + ) -> None: + target = FamiTrackerTarget(program=_program(tmp_path, b"not an NSF file at all")) + + with pytest.raises(FamiTrackerError, match="failed to play"): + target.play(project, documents, NAME) + + def test_a_project_a_module_has_no_room_for_is_reported( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + documents: Path, + project: Project, + ) -> None: + def build_module(refused: Project) -> None: + raise ValueError("Order length 129 exceeds the FamiTracker limit of 128 frames") + + monkeypatch.setattr(target_module, "build_module", build_module) + + with pytest.raises(FamiTrackerError, match="129"): + FamiTrackerTarget(program=_program(tmp_path, marking_nsf())).play(project, documents, NAME) diff --git a/tests/unit/sampletones_tools/tracker_playback/targets/famitracker/test_trace.py b/tests/unit/sampletones_tools/tracker_playback/targets/famitracker/test_trace.py new file mode 100644 index 000000000..b1cd9caf9 --- /dev/null +++ b/tests/unit/sampletones_tools/tracker_playback/targets/famitracker/test_trace.py @@ -0,0 +1,109 @@ +from typing import Final, List, Sequence, Tuple + +import pytest + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.timing.bounds import MAX_TICKS_PER_ROW +from sampletones_player.specification.registers import ( + APU_STATUS, + DMC_DIRECT_LOAD, + PULSE1_CONTROL, + PULSE1_TIMER_HIGH, + PULSE1_TIMER_LOW, +) +from sampletones_tools.player.trace.write import RegisterWrite +from sampletones_tools.tracker_playback.targets.famitracker.errors import FamiTrackerError +from sampletones_tools.tracker_playback.targets.famitracker.markers import row_marker +from sampletones_tools.tracker_playback.targets.famitracker.trace import DriverWrite, recorded_trace +from sampletones_tools.tracker_playback.trace.sound import TickPosition + +ENABLED: Final[RegisterWrite] = RegisterWrite(APU_STATUS, 0x0F) +LOUD: Final[RegisterWrite] = RegisterWrite(PULSE1_CONTROL, 0xBF) +QUIET: Final[RegisterWrite] = RegisterWrite(PULSE1_CONTROL, 0xB4) +TIMER: Final[Tuple[RegisterWrite, RegisterWrite]] = ( + RegisterWrite(PULSE1_TIMER_LOW, 0xAB), + RegisterWrite(PULSE1_TIMER_HIGH, 0x01), +) + + +def marker(row_index: int) -> RegisterWrite: + return RegisterWrite(DMC_DIRECT_LOAD, row_marker(row_index)) + + +class ScriptedCalls: + """Routines answering with the writes a case scripts, one play call after another, then nothing.""" + + def __init__( + self, + initialization: Tuple[RegisterWrite, ...], + play_calls: Sequence[Tuple[RegisterWrite, ...]], + ) -> None: + self._initialization = initialization + self._play_calls: List[Tuple[RegisterWrite, ...]] = list(play_calls) + + def initialize(self) -> Tuple[RegisterWrite, ...]: + return self._initialization + + def play(self) -> Tuple[RegisterWrite, ...]: + return self._play_calls.pop(0) if self._play_calls else () + + +class TestSplittingThePlayCalls: + """A song of one frame and two rows, the first row lasting two ticks and the second one.""" + + @pytest.fixture(name="calls") + def calls_fixture(self) -> ScriptedCalls: + return ScriptedCalls( + (ENABLED,), + ( + (marker(0), *TIMER, LOUD), + (QUIET,), + (marker(1), LOUD), + (marker(2),), + ), + ) + + def test_a_tick_falls_on_the_row_its_last_marker_started(self, calls: ScriptedCalls) -> None: + trace = recorded_trace(calls, frames=1, rows=2) + + assert [(tick.frame, tick.row) for tick in trace.ticks] == [(0, 0), (0, 0), (0, 1)] + + def test_the_pass_ends_where_the_order_comes_back_round(self, calls: ScriptedCalls) -> None: + assert len(recorded_trace(calls, frames=1, rows=2).ticks) == 3 + + def test_each_tick_keeps_the_writes_its_call_made(self, calls: ScriptedCalls) -> None: + trace = recorded_trace(calls, frames=1, rows=2) + + assert trace.ticks[1].writes == (DriverWrite(address=QUIET.address, value=QUIET.value),) + assert trace.initialization == (DriverWrite(address=ENABLED.address, value=ENABLED.value),) + + def test_the_channels_sound_what_the_registers_hold_on_each_tick(self, calls: ScriptedCalls) -> None: + song = recorded_trace(calls, frames=1, rows=2).song_trace() + pulse = song.channels[ChannelName.PULSE1] + + assert song.positions == (TickPosition(0, 0), TickPosition(0, 0), TickPosition(0, 1)) + assert [sound.volume for sound in pulse] == [0x0F, 0x04, 0x0F] + assert {sound.period for sound in pulse} == {0x1AB} + assert all(sound.audible for sound in pulse) + + +class TestTheCallsAroundTheRows: + def test_calls_before_the_first_row_join_its_first_tick(self) -> None: + calls = ScriptedCalls((), ((LOUD,), (marker(0), QUIET), (marker(1),))) + + (tick,) = recorded_trace(calls, frames=1, rows=1).ticks + + assert [write.address for write in tick.writes] == [PULSE1_CONTROL, DMC_DIRECT_LOAD, PULSE1_CONTROL] + + def test_a_row_marked_out_of_place_is_reported(self) -> None: + skipped = RegisterWrite(DMC_DIRECT_LOAD, row_marker(2)) + calls = ScriptedCalls((), ((marker(0),), (skipped,))) + + with pytest.raises(FamiTrackerError, match="marked row 1"): + recorded_trace(calls, frames=1, rows=4) + + def test_a_row_lasting_longer_than_any_project_holds_one_is_reported(self) -> None: + calls = ScriptedCalls((), ((marker(0),),)) + + with pytest.raises(FamiTrackerError, match=str(MAX_TICKS_PER_ROW)): + recorded_trace(calls, frames=1, rows=2) diff --git a/tests/unit/sampletones_tools/tracker_playback/test_command.py b/tests/unit/sampletones_tools/tracker_playback/test_command.py index 3f861c27f..997904bb1 100644 --- a/tests/unit/sampletones_tools/tracker_playback/test_command.py +++ b/tests/unit/sampletones_tools/tracker_playback/test_command.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass, field from pathlib import Path from typing import Final, List, Sequence @@ -5,54 +6,71 @@ from sampletones.commands.registry import COMMANDS from sampletones.dispatcher import dispatch +from sampletones_core.exporters.skipped import NO_SKIPPED_ROWS +from sampletones_core.project.container import ProjectContainer from sampletones_core.project.project import Project from sampletones_tools.tracker_playback.comparison import TraceComparison -from sampletones_tools.tracker_playback.corpus.build import CorpusProject from sampletones_tools.tracker_playback.outcome import ProjectOutcome +from sampletones_tools.tracker_playback.projects import CheckedProject from sampletones_tools.tracker_playback.report import MATCHES from sampletones_tools.tracker_playback.session import PlaybackOutcome, PlaybackRun from sampletones_tools.tracker_playback.targets.bitphase.engine import EngineError from sampletones_tools.tracker_playback.targets.bitphase.target import BitphaseTarget +from sampletones_tools.tracker_playback.targets.famitracker.errors import FamiTrackerError +from sampletones_tools.tracker_playback.targets.famitracker.target import FamiTrackerTarget from tests.suite.playback import ReplayingTarget COMMAND: Final[str] = "tracker-playback" COMPARISON_CORPUS: Final[str] = "sampletones_tools.tracker_playback.corpus.build.comparison_corpus" -CHECK_CORPUS: Final[str] = "sampletones_tools.tracker_playback.session.check_corpus" +CHECK_PROJECTS: Final[str] = "sampletones_tools.tracker_playback.session.check_projects" DEFAULT_OUTPUT: Final[str] = "sampletones_tools.tracker_playback.session.default_output" +CORPUS_PROJECT: Final[str] = "corpus-tone" def _matching_outcome(name: str) -> ProjectOutcome: return ProjectOutcome( - project=CorpusProject(name=name, purpose="A tone.", project=Project.create()), + project=CheckedProject(name=name, purpose="A tone.", project=Project.create()), comparison=TraceComparison(application_ticks=6, engine_ticks=6, timing=None, divergences=()), - skipped_rows=0, + skipped_rows=NO_SKIPPED_ROWS, truncation=None, ) -@pytest.fixture(name="runs") -def runs_fixture( +@dataclass +class StartedRuns: + """What the command started: each run, and the names of the projects it was handed.""" + + runs: List[PlaybackRun] = field(default_factory=list) + projects: List[List[str]] = field(default_factory=list) + + +@pytest.fixture(name="started") +def started_fixture( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, replaying_target: ReplayingTarget, -) -> List[PlaybackRun]: - """The runs the command starts, each answered with one matching project, the target located as a replay.""" - runs: List[PlaybackRun] = [] +) -> StartedRuns: + """The runs the command starts, each answered with one matching project, the target located as a replay. - def check_corpus(projects: Sequence[CorpusProject], run: PlaybackRun) -> PlaybackOutcome: - runs.append(run) + The corpus is a single project named ``CORPUS_PROJECT``. + """ + started = StartedRuns() + + def check_projects(projects: Sequence[CheckedProject], run: PlaybackRun) -> PlaybackOutcome: + started.runs.append(run) + started.projects.append([project.name for project in projects]) return PlaybackOutcome(outcomes=(_matching_outcome("tone"),), report=tmp_path / "report.md") monkeypatch.setattr(BitphaseTarget, "located", classmethod(lambda cls, root: replaying_target)) - monkeypatch.setattr(COMPARISON_CORPUS, lambda corpus: []) - monkeypatch.setattr(CHECK_CORPUS, check_corpus) - return runs + monkeypatch.setattr(COMPARISON_CORPUS, lambda corpus: [_matching_outcome(CORPUS_PROJECT).project]) + monkeypatch.setattr(CHECK_PROJECTS, check_projects) + return started class TestTrackerPlayback: def test_each_verdict_and_the_report_link_are_printed( self, - runs: List[PlaybackRun], + started: StartedRuns, replaying_target: ReplayingTarget, tmp_path: Path, capsys: pytest.CaptureFixture[str], @@ -60,7 +78,7 @@ def test_each_verdict_and_the_report_link_are_printed( arguments = [COMMAND, "bitphase", "--checkout", str(tmp_path), "-o", str(tmp_path / "run")] assert dispatch(COMMANDS, arguments) == 0 - (run,) = runs + (run,) = started.runs assert (run.target, run.output) == (replaying_target, tmp_path / "run") assert run.settings.examples_per_difference >= 1 assert capsys.readouterr().out.splitlines() == [ @@ -70,14 +88,48 @@ def test_each_verdict_and_the_report_link_are_printed( def test_a_run_given_no_output_writes_where_the_default_names( self, - runs: List[PlaybackRun], + started: StartedRuns, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: monkeypatch.setattr(DEFAULT_OUTPUT, lambda: tmp_path / "stamped") assert dispatch(COMMANDS, [COMMAND, "bitphase", "--checkout", str(tmp_path)]) == 0 - assert [run.output for run in runs] == [tmp_path / "stamped"] + assert [run.output for run in started.runs] == [tmp_path / "stamped"] + + def test_a_run_given_no_project_checks_the_corpus(self, started: StartedRuns, tmp_path: Path) -> None: + assert dispatch(COMMANDS, [COMMAND, "bitphase", "--checkout", str(tmp_path)]) == 0 + assert started.projects == [[CORPUS_PROJECT]] + + def test_a_run_given_projects_checks_those_alone_in_the_order_given( + self, + started: StartedRuns, + tmp_path: Path, + ) -> None: + for name in ("verse", "chorus"): + ProjectContainer.save(Project.create(), tmp_path / f"{name}.stp") + + arguments = [ + COMMAND, + "bitphase", + "--checkout", + str(tmp_path), + "--project", + str(tmp_path / "chorus.stp"), + "--project", + str(tmp_path / "verse.stp"), + ] + + assert dispatch(COMMANDS, arguments) == 0 + assert started.projects == [["chorus", "verse"]] + + def test_a_project_that_fails_to_open_is_reported(self, started: StartedRuns, tmp_path: Path) -> None: + arguments = [COMMAND, "bitphase", "--checkout", str(tmp_path), "--project", str(tmp_path / "absent.stp")] + + with pytest.raises(SystemExit, match="absent.stp"): + dispatch(COMMANDS, arguments) + + assert not started.runs def test_a_tracker_that_cannot_run_is_reported( self, @@ -98,6 +150,45 @@ def test_the_bitphase_checkout_is_required(self) -> None: assert leaving.value.code == 2 + def test_the_famitracker_target_plays_through_the_program_given( + self, + started: StartedRuns, + monkeypatch: pytest.MonkeyPatch, + replaying_target: ReplayingTarget, + tmp_path: Path, + ) -> None: + located: List[Path] = [] + + def locate(cls: type, executable: Path) -> ReplayingTarget: + located.append(executable) + return replaying_target + + monkeypatch.setattr(FamiTrackerTarget, "located", classmethod(locate)) + executable = tmp_path / "FamiTracker.exe" + + assert dispatch(COMMANDS, [COMMAND, "famitracker", "--executable", str(executable)]) == 0 + assert located == [executable] + assert [run.target for run in started.runs] == [replaying_target] + + def test_a_famitracker_that_cannot_run_is_reported( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + ) -> None: + def located(cls: type, executable: Path) -> FamiTrackerTarget: + raise FamiTrackerError("wine is missing") + + monkeypatch.setattr(FamiTrackerTarget, "located", classmethod(located)) + + with pytest.raises(SystemExit, match="wine is missing"): + dispatch(COMMANDS, [COMMAND, "famitracker", "--executable", str(tmp_path / "FamiTracker.exe")]) + + def test_the_famitracker_program_is_required(self) -> None: + with pytest.raises(SystemExit) as leaving: + dispatch(COMMANDS, [COMMAND, "famitracker"]) + + assert leaving.value.code == 2 + def test_a_target_is_required(self) -> None: with pytest.raises(SystemExit) as leaving: dispatch(COMMANDS, [COMMAND]) diff --git a/tests/unit/sampletones_tools/tracker_playback/test_projects.py b/tests/unit/sampletones_tools/tracker_playback/test_projects.py new file mode 100644 index 000000000..03fa55a15 --- /dev/null +++ b/tests/unit/sampletones_tools/tracker_playback/test_projects.py @@ -0,0 +1,74 @@ +from pathlib import Path +from typing import Final, Set + +import pytest + +from sampletones_core.project.container import ProjectContainer +from sampletones_core.project.project import Project +from sampletones_shared.exceptions.project import NotAValidArchiveError +from sampletones_tools.tracker_playback.projects import ( + COUNTED_NAME, + FIRST_COUNT, + LOADED_PURPOSE, + loaded_projects, + unique_name, +) + +FIRST_TITLE: Final[str] = "First song" +SECOND_TITLE: Final[str] = "Second song" + + +def _saved(path: Path, title: str) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + ProjectContainer.save(Project.create(title=title), path) + return path + + +class TestLoadedProjects: + def test_each_file_becomes_a_project_named_after_it_in_the_order_given(self, tmp_path: Path) -> None: + second = _saved(tmp_path / "second.stp", SECOND_TITLE) + first = _saved(tmp_path / "first.stp", FIRST_TITLE) + + projects = loaded_projects((second, first)) + + assert [project.name for project in projects] == ["second", "first"] + assert [project.project.info.title for project in projects] == [SECOND_TITLE, FIRST_TITLE] + + def test_a_project_says_which_file_it_came_from(self, tmp_path: Path) -> None: + path = _saved(tmp_path / "song.stp", FIRST_TITLE) + + (project,) = loaded_projects((path,)) + + assert project.purpose == LOADED_PURPOSE.format(path=path.resolve()) + + def test_files_sharing_a_name_write_apart(self, tmp_path: Path) -> None: + first = _saved(tmp_path / "one" / "song.stp", FIRST_TITLE) + second = _saved(tmp_path / "two" / "song.stp", SECOND_TITLE) + + projects = loaded_projects((first, second)) + + assert [project.name for project in projects] == [ + "song", + COUNTED_NAME.format(stem="song", count=FIRST_COUNT), + ] + + def test_a_file_holding_no_project_is_refused(self, tmp_path: Path) -> None: + path = tmp_path / "notes.stp" + path.write_text("not a project", encoding="utf-8") + + with pytest.raises(NotAValidArchiveError): + loaded_projects((path,)) + + def test_a_missing_file_is_refused(self, tmp_path: Path) -> None: + with pytest.raises(OSError): + loaded_projects((tmp_path / "absent.stp",)) + + +class TestUniqueName: + def test_a_free_name_is_kept(self) -> None: + assert unique_name("song", {"other"}) == "song" + + def test_a_taken_name_takes_the_lowest_free_count(self) -> None: + taken: Set[str] = {"song", COUNTED_NAME.format(stem="song", count=FIRST_COUNT)} + + assert unique_name("song", taken) == COUNTED_NAME.format(stem="song", count=FIRST_COUNT + 1) diff --git a/tests/unit/sampletones_tools/tracker_playback/test_report.py b/tests/unit/sampletones_tools/tracker_playback/test_report.py index 8e1f5d00f..7f565a5e6 100644 --- a/tests/unit/sampletones_tools/tracker_playback/test_report.py +++ b/tests/unit/sampletones_tools/tracker_playback/test_report.py @@ -2,11 +2,12 @@ from typing import Final, Tuple from sampletones_core.constants.enums import ChannelName +from sampletones_core.exporters.skipped import NO_SKIPPED_ROWS, SkippedRow, SkipReason from sampletones_core.exporters.truncation import EnvelopeTruncation from sampletones_core.project.project import Project from sampletones_tools.tracker_playback.comparison import TraceComparison, compare_traces -from sampletones_tools.tracker_playback.corpus.build import CorpusProject from sampletones_tools.tracker_playback.outcome import ProjectOutcome +from sampletones_tools.tracker_playback.projects import CheckedProject from sampletones_tools.tracker_playback.report import ( COUNTED_DOWN, DIFFERS, @@ -46,11 +47,15 @@ def _trace(pulse: Tuple[ChannelSound, ...]) -> SongTrace: ) +def _skipped(reason: SkipReason, row: int) -> SkippedRow: + return SkippedRow(voice_id="voice", channel=ChannelName.PULSE1, order_position=0, row_index=row, reason=reason) + + def _outcome(name: str, comparison: TraceComparison) -> ProjectOutcome: return ProjectOutcome( - project=CorpusProject(name=name, purpose=f"What {name} exercises.", project=Project.create()), + project=CheckedProject(name=name, purpose=f"What {name} exercises.", project=Project.create()), comparison=comparison, - skipped_rows=0, + skipped_rows=NO_SKIPPED_ROWS, truncation=None, ) @@ -107,7 +112,11 @@ def test_the_lengths_the_timing_and_what_the_export_left_out_are_stated( engine = SongTrace(positions=(TickPosition(frame=0, row=0),) * 3, channels=_trace((TONE,) * 3).channels) outcome = replace( _outcome("long", compare_traces(application, engine, examples=EXAMPLES)), - skipped_rows=2, + skipped_rows=( + _skipped(SkipReason.NO_INSTRUMENT, 0), + _skipped(SkipReason.UNREACHED_TRANSPOSE, 1), + _skipped(SkipReason.NO_INSTRUMENT, 2), + ), truncation=EnvelopeTruncation(frames=512, source_frames=601, instruments=1), ) @@ -115,7 +124,8 @@ def test_the_lengths_the_timing_and_what_the_export_left_out_are_stated( assert LENGTHS_DIFFER.format(application=2, engine=3) in text assert TIMING_DIFFERS.format(tick=1, frame=0, row=1, engine_frame=0, engine_row=0) in text - assert SKIPPED_ROWS.format(count=2) in text + assert SKIPPED_ROWS[SkipReason.NO_INSTRUMENT].format(count=2) in text + assert SKIPPED_ROWS[SkipReason.UNREACHED_TRANSPOSE].format(count=1) in text assert SHORTENED.format(instruments=1, frames=512, source_frames=601) in text diff --git a/tests/unit/sampletones_tools/tracker_playback/test_session.py b/tests/unit/sampletones_tools/tracker_playback/test_session.py index d4fbd991e..5e8de3d0b 100644 --- a/tests/unit/sampletones_tools/tracker_playback/test_session.py +++ b/tests/unit/sampletones_tools/tracker_playback/test_session.py @@ -3,13 +3,13 @@ from sampletones_core.constants.enums import ChannelName from sampletones_core.project.settings import ProjectSettings -from sampletones_tools.tracker_playback.corpus.build import CorpusProject from sampletones_tools.tracker_playback.paths import ( DOCUMENTS_DIRECTORY_NAME, OUTPUT_ROOT, REPORT_FILENAME, ) -from sampletones_tools.tracker_playback.session import PlaybackRun, check_corpus, default_output +from sampletones_tools.tracker_playback.projects import CheckedProject +from sampletones_tools.tracker_playback.session import PlaybackRun, check_projects, default_output from sampletones_tools.tracker_playback.settings import PlaybackSettings from tests.suite.performance import make_pulse_reconstruction, place_instrument, project_with_sample from tests.suite.playback import ReplayingTarget @@ -18,24 +18,24 @@ SETTINGS: Final[PlaybackSettings] = PlaybackSettings(examples_per_difference=2) -def _corpus_project() -> CorpusProject: +def _checked_project() -> CheckedProject: project, sample = project_with_sample( make_pulse_reconstruction(count=4), rows_per_pattern=2, settings=ProjectSettings(tempo=150, speed=6, nes_frequency=60), ) place_instrument(project, channel_name=ChannelName.PULSE1, row_index=0, sample=sample, volume=15) - return CorpusProject(name=NAME, purpose="A tone.", project=project) + return CheckedProject(name=NAME, purpose="A tone.", project=project) -class TestCheckCorpus: +class TestCheckProjects: def test_each_project_is_played_into_the_documents_and_the_report_is_written_beside_them( self, tmp_path: Path, replaying_target: ReplayingTarget, ) -> None: - outcome = check_corpus( - (_corpus_project(),), + outcome = check_projects( + (_checked_project(),), PlaybackRun(target=replaying_target, output=tmp_path, settings=SETTINGS), ) @@ -45,7 +45,7 @@ def test_each_project_is_played_into_the_documents_and_the_report_is_written_bes (project_outcome,) = outcome.outcomes assert project_outcome.project.name == NAME assert project_outcome.comparison.matches - assert (project_outcome.skipped_rows, project_outcome.truncation) == (0, None) + assert (project_outcome.skipped_rows, project_outcome.truncation) == ((), None) class TestDefaultOutput: diff --git a/tests/unit/sampletones_tools/tracker_playback/trace/test_application.py b/tests/unit/sampletones_tools/tracker_playback/trace/test_application.py index a2252d894..6d05aec9b 100644 --- a/tests/unit/sampletones_tools/tracker_playback/trace/test_application.py +++ b/tests/unit/sampletones_tools/tracker_playback/trace/test_application.py @@ -1,8 +1,9 @@ +from fractions import Fraction from typing import Final from sampletones_core.constants.enums import ChannelName from sampletones_core.project.settings import ProjectSettings -from sampletones_core.timing import Groove +from sampletones_core.timing import SONG_TICK_BOUNDS, Meter, RowRate, SongTiming from sampletones_player.specification.registers import ( APU_STATUS, CHANNELS_ENABLED, @@ -41,15 +42,21 @@ class TestSongPositions: - def test_each_row_lasts_the_ticks_its_groove_gives_it_in_every_frame(self) -> None: - positions = song_positions(Groove(ticks=(2, 1)), 2) + def test_each_row_lasts_the_ticks_its_frame_gives_it(self) -> None: + """At 5/4 ticks a row a 2-row pattern lasts 2.5 ticks, so the first frame plays 3 and the second 2.""" + timing = SongTiming( + rate=RowRate(ticks_per_row=Fraction(5, 4)), + meter=Meter(rows=2, first_highlight=2, second_highlight=2), + bounds=SONG_TICK_BOUNDS, + ) + + positions = song_positions(timing, 2) assert positions == ( TickPosition(frame=0, row=0), TickPosition(frame=0, row=0), TickPosition(frame=0, row=1), TickPosition(frame=1, row=0), - TickPosition(frame=1, row=0), TickPosition(frame=1, row=1), ) diff --git a/uv.lock b/uv.lock index 34b73e0a2..f82d19b6c 100644 --- a/uv.lock +++ b/uv.lock @@ -1807,6 +1807,7 @@ dependencies = [ { name = "msgpack" }, { name = "numpy" }, { name = "pebble" }, + { name = "py65" }, { name = "pyaudio" }, { name = "pydantic" }, { name = "pytaskbar", marker = "sys_platform == 'win32' or (extra == 'extra-11-sampletones-gpu' and extra == 'extra-11-sampletones-gpu-cuda11')" }, @@ -1846,7 +1847,6 @@ dev = [ { name = "mypy" }, { name = "pillow" }, { name = "pre-commit" }, - { name = "py65" }, { name = "pylint" }, { name = "pylint-pydantic" }, { name = "pytest" }, @@ -1869,6 +1869,7 @@ requires-dist = [ { name = "nvidia-cuda-nvrtc-cu11", marker = "sys_platform == 'linux' and extra == 'gpu-cuda11'" }, { name = "nvidia-cuda-runtime-cu11", marker = "sys_platform == 'linux' and extra == 'gpu-cuda11'" }, { name = "pebble", specifier = ">=5.0,<6" }, + { name = "py65", specifier = ">=1.2,<2" }, { name = "pyaudio", specifier = ">=0.2.14,<0.3" }, { name = "pydantic", specifier = ">=2.9,<3" }, { name = "pyinstaller", marker = "extra == 'build'", specifier = ">=6.21,<7" }, @@ -1892,7 +1893,6 @@ dev = [ { name = "mypy", specifier = "==2.1.0" }, { name = "pillow", specifier = ">=11,<13" }, { name = "pre-commit", specifier = "==4.6.0" }, - { name = "py65", specifier = "==1.2.0" }, { name = "pylint", specifier = "==4.0.6" }, { name = "pylint-pydantic", specifier = "==0.4.1" }, { name = "pytest", specifier = "==9.1.1" },