diff --git a/Makefile b/Makefile index c18c11391..f4d39edd2 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help setup install system-deps build release run calibration clean pre-commit test test-docs benchmarks lint format +.PHONY: help setup install system-deps build release run calibration tracker-playback clean pre-commit test test-docs benchmarks lint format ifeq ($(OS),Windows_NT) PYTHON := python @@ -25,6 +25,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 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) @@ -52,6 +53,9 @@ run: calibration: uv run sampletones calibration +tracker-playback: + uv run sampletones tracker-playback bitphase --checkout $(BITPHASE) + clean: $(PYTHON) scripts/clean.py diff --git a/docs/concepts/compression.md b/docs/concepts/compression.md index e0cd640ea..de79b80d2 100644 --- a/docs/concepts/compression.md +++ b/docs/concepts/compression.md @@ -22,12 +22,12 @@ through, the same slices the sequencer sounds a row in. On every tick each of th [channels](../glossary.md#channel) has a full set of register values. Written out plainly, that is 11 bytes a tick: three each for the two pulse channels and the triangle, two for the noise. -At 60 ticks a second, 11 bytes a tick fills the space behind the driver in **49 seconds**. A song of three +At 60 ticks a second, 11 bytes a tick fills the space behind the driver in **48 seconds**. A song of three minutes needs 118800 bytes, and the console has about 32000. So either songs stay under a minute, or the stream is stored in a form the driver can unpack as it plays. The content in those bytes is far smaller than the bytes themselves. What a tick says is a volume, a duty -cycle, a pitch and a noise period, roughly four bytes' worth even before anything repeats. And a great +cycle, a pitch and a noise period, roughly five bytes' worth even before anything repeats. And a great deal repeats. A channel resting through a passage writes the same three bytes over and over. A song built by playing the same drum sample at many rows writes that sample's envelopes once per row. @@ -68,14 +68,17 @@ its first bent tick to its last. The bend plane holds those ticks' steps alone, its own. A note played straight costs it nothing, and a channel that never bends leaves it out of the block. A loop re-enters it at the value its flags have reached, which is a boundary like any other. -Before any phrase, trading two dividers for an index and a bend already pays. On the arrangement of -[section 6](#6-what-it-achieves), which bends most of its notes, the planes and their pitch table take -3.229 bytes a tick against 3.517. Both figures are coded with no plane packing its repeats -([section 2.3](#23-a-value-and-the-ticks-it-lasts)). The index pays because a bend plane has a value only -where a note bends. What it earns beyond that is that **a pitch index can be transposed and a divider -cannot.** The same figure played at five pitches is five unrelated byte sequences in divider space. In -index space it is one sequence and five offsets, and its bend is the same bytes throughout. That turns a -repeated sample into a single dictionary entry ([section 5](#5-the-dictionary)). +Before any phrase, these planes already take less than a plane per register. On the arrangement of +[section 6](#6-what-it-achieves), the planes and their pitch table take 3.229 bytes a tick against 3.517. +Both figures are coded in holds and literals ([section 3](#3-the-token-language)), with no plane packing +its repeats ([section 2.3](#23-a-value-and-the-ticks-it-lasts)). The saving comes from the triangle. Its +value plane names silence, so its control byte leaves the block. On their own, the index and the bend +take 2.5 % more than the dividers. The arrangement bends every note its first pulse channel plays, so +that channel's bend plane has a value on every tick. What the index earns is that **a pitch index can be +transposed and a divider cannot.** The same figure played at five pitches is five unrelated byte +sequences in divider space. In index space it is one sequence and five offsets, and its bend is the same +bytes throughout. That turns a repeated sample into a single dictionary entry +([section 5](#5-the-dictionary)). ### 2.3 A value and the ticks it lasts @@ -156,7 +159,7 @@ it prices all of a plane's literals in one pass. The obvious alternative takes the longest phrase that matches at each symbol, otherwise a hold, otherwise a literal. It goes wrong constantly. Taking a 40-symbol phrase for two bytes looks better than taking a -30-symbol one, until stopping at 30 would have let the next 200 symbols be a single hold. Costs also +30-symbol one, until stopping at 30 would have let the next 60 symbols be a single hold. Costs also depend on the dictionary: the same phrase is two bytes with a cheap id and four with an escaped id and a shift. The search weighs those against each other, and a rule of thumb cannot. @@ -211,8 +214,8 @@ gain = what the current parse pays for those spans today − the entry the phrase takes in the dictionary ``` -Scoring against **the current parse** and not against raw length keeps the search honest. A run of 200 -identical values looks enormous by length and is worth nothing, because a hold already covers it for one +Scoring against **the current parse** and not against raw length keeps the search honest. A run of 40 +identical values looks large by length and is worth nothing, because a hold already covers it for one byte. Only spans the parse is paying for can pay a candidate back. The best few candidates of each round are then confirmed the expensive way. The whole song is parsed again @@ -246,30 +249,32 @@ could begin and not every symbol of the song. ## 6. What it achieves -Measured over a three-minute arrangement of 10800 ticks, each layer added to the ones above it: +Measured by `uv run sampletones codec report` over its three-minute arrangement of 10800 ticks, counting +the dictionary, the streams and the pitch table. Each row builds on the one above it: | what is stored | bytes per tick | ratio | ticks that fit | |---|---|---|---| | a record per tick per channel | 11.000 | 1.00 | 2907 | -| planes, coded | 3.517 | 3.13 | 9092 | -| planes with a pitch index and a bend | 2.980 | 3.69 | 10731 | +| a plane per register, in holds and literals | 3.517 | 3.13 | 9092 | +| planes with a pitch index and a bend, repeats packed | 2.980 | 3.69 | 10731 | | phrases from the instruments | 1.717 | 6.41 | 18750 | | phrases played transposed | 1.447 | 7.60 | 22326 | | phrases from the search as well | **0.874** | **12.59** | **37660** | -The arrangement bends most of the notes its pulse channel plays, and its bend plane carries every one of -them. A song played straight leaves its bend planes out of the block. The whole song is 9435 bytes of the -roughly 32000 available, and **37660 ticks is 10.5 minutes at 60 Hz**, against the 49 seconds a record per -tick reaches. Encoding happens once, where the file is written. Decoding costs the console around twenty -instructions per plane per tick, and fewer on a tick a symbol still covers, well inside a video frame. +The arrangement bends every note its first pulse channel plays, and that channel's bend plane carries +every one of them. A song played straight leaves its bend planes out of the block. The song takes 9435 +bytes of the roughly 32000 available, and **37660 ticks is 10.5 minutes at 60 Hz**, against the 48 seconds +a record per tick reaches. Encoding happens once, where the file is written. Decoding the arrangement +costs the console around twenty instructions per plane per tick, and fewer on a tick a symbol still +covers, well inside a video frame. The format's constants are settled from a corpus of songs. Two results went against expectation. Splitting the duty cycle out of the control byte into a plane of its own **costs** bytes. On the -arrangement above, encoded at every layer, it costs 16 % where no plane packs, because volume and duty -turn over together, and a split pays two opcodes for what one covers. It costs 56 % where the planes pack +arrangement above, encoded at every layer, it costs 5 % where no plane packs, because volume and duty +turn over together, and a split pays two opcodes for what one covers. It costs 19 % where the planes pack as the format packs them, because a split plane also gives up the repeat count its register's spare bits carry. The pitch index earns its place through the transposition it makes possible, the fourth row of the -table against the fifth, and it pays for itself directly as well +table against the fifth. On its own, before any phrase, it costs a little ([section 2.2](#22-pitches-instead-of-dividers)). An export chooses how far down these layers it goes. Its **Level** names the layers read in order: diff --git a/docs/development/application/dialogs.md b/docs/development/application/dialogs.md index 93d6c90c2..65d6217f8 100644 --- a/docs/development/application/dialogs.md +++ b/docs/development/application/dialogs.md @@ -1,8 +1,9 @@ # Dialogs A dialog sets its size once, and where it opens follows from that size. Consult this when a dialog opens at -the wrong size or in the wrong place, and when adding one. `GUIWindow` is the single place a dialog's -window is opened, so what this document says holds for every dialog the application raises. +the wrong size or in the wrong place, when a prompt raised from an answer never shows, and when adding one. +`GUIWindow` is the single place a dialog's window is opened, so what this document says holds for every +dialog the application raises. ## What a dialog sets @@ -42,6 +43,45 @@ where it stands. Each axis is held at zero at the least, so a dialog taller than the viewport keeps its title bar reachable. +## One modal at a time + +DearPyGui shows one modal at a time. A modal built while another stands opens hidden, where nobody can +reach it, and its title-bar close runs as though the reader had dismissed it. A modal built in the frame +another one left in meets the same fate, since that frame still draws the one that left. + +The screen therefore belongs to one conversation at a time. A conversation is a dialog, the modals it +hands the screen to while it steps aside, and the ones its answers raise. A modal asked for from anywhere +else, such as the report of a job that finished or a prompt raised by a gesture that waited for edits, +waits in line. It opens once the conversation holding the screen has ended, a frame after its last window +left, and the line opens in the order it was asked. A dialog asked for again while it waits keeps its place +with the newer request, and one hidden while it waits leaves the line. + +`ModalQueue` (`utils/gui/modal_queue.py`) keeps the line, and `GUIWindow.show` is the only way into it, so +a caller raises a dialog whenever it has one to raise and never waits a frame of its own for the screen. A +window that reports work under way and leaves the rest of the interface live beside it is no modal, so it +opens at once. + +## How a dialog answers + +A dialog that closes on its answer leaves the screen first, and the answer runs a frame later as a hand-off +of its conversation. Whatever the answer raises, such as a question of its own or an error, opens ahead of +the line. Leaving also releases the dialog's keyboard claim, so a prompt the answer raises holds the +keyboard alone. `GUIWindow._leave_then` is that step. + +What the answer needs, such as a ticked box or the fields of a form, is read before the dialog leaves. Only +the first answer runs: a second click reaches a dialog that has already gone. + +The save prompt's Save runs the save once the prompt has gone, and the save reports a `SaveOutcome`. A +document written to disk goes on to what the prompt was guarding. A save the reader called off, such as a +file dialog closed without a name, brings the prompt back with the same question. A save that failed has +shown its error, and that error stands alone on screen. A save the prompt asked for shows no message of its +own when it lands, since the reader asked to go on and what the prompt guards opens next. A document with +no file to write to asks for one, the way Save As does. + +A dialog that comes back once the modal it raised is answered steps aside. `yield_to` takes it off screen +and keeps its tree, and `resume` brings it back, a frame each way. The dialog keeps the screen while it +stands aside, so nothing waiting in line opens between it and the prompt it raised. + ## Where it is written `GUIWindow.dialog_window` (`ui/elements/window.py`) is the only place a dialog's `dpg.window` is opened. diff --git a/docs/development/application/keyboard.md b/docs/development/application/keyboard.md index 8ef52c06b..87bd5e1a8 100644 --- a/docs/development/application/keyboard.md +++ b/docs/development/application/keyboard.md @@ -44,7 +44,9 @@ The query resolves the focused item to the field behind it. A `dpg.group` report ### The modal stack -The router holds a LIFO stack of modal handlers. `push_modal` and `pop_modal` bracket a dialog's lifetime, and the built-in `MODAL` scope routes each press to the top of the stack. `MODAL` outranks the panel and shortcut scopes, so every scope beneath it reads the keyboard as though the application had no dialogs at all. +The router holds a stack of modal handlers. `push_modal` and `pop_modal` bracket a dialog's lifetime, and the built-in `MODAL` scope routes each press to the top of the stack. `MODAL` outranks the panel and shortcut scopes, so every scope beneath it reads the keyboard as though the application had no dialogs at all. + +A release names its handler and removes that handler's latest claim, wherever it stands. A dialog can close while a prompt it raised still stands, and the prompt keeps the keyboard. --- diff --git a/docs/development/application/playback.md b/docs/development/application/playback.md index 56351adf8..21a91cc0d 100644 --- a/docs/development/application/playback.md +++ b/docs/development/application/playback.md @@ -92,11 +92,15 @@ Muting is monitoring, and principle 5 governs what follows. The project holds ev ## What the channel holds -A sample has a value for every dimension of every frame, and its reconstruction names the dimensions the channel governs. The instrument writes the rest itself. Each channel carries a value per dimension (volume, arpeggio, timbre), and an instrument that leaves one empty sounds it at the value the channel holds. That is what clearing an envelope in the instruments panel means once the sample is played in a song. A FamiTracker instrument follows the same rule with a sequence left out. +A sample has a value for every dimension of every frame, and its reconstruction names the dimensions the channel governs. The instrument writes the rest itself. Each channel carries a value per dimension (volume, arpeggio, bend, timbre), and an instrument that leaves one empty sounds it at the value the channel holds. That is what clearing an envelope in the instruments panel means once the sample is played in a song. A FamiTracker instrument follows the same rule with a sequence left out. -The value moves as the song plays. Every frame an instrument writes hands its value to the channel, so the channel keeps the last one written, and an instrument that leaves the dimension empty picks it up. A silent frame sets its level alone and leaves pitch and timbre where the channel holds them. +Every note starts those values where a song starts them: full volume, no arpeggio offset, no bend, the first timbre (duty 0 on a pulse channel, the long mode on noise). A dimension the instrument leaves empty therefore sounds at that start for the whole note, whatever the note before it wrote. An empty volume plays at the row's level. FamiTracker and Bitphase start a note the same way, so an exported song plays in the tracker as it does here. -A pass through the song begins on the values a channel holds from the start: full volume, no arpeggio offset, the first timbre. Starting the song and looping back to its first row therefore sound the same. Seeking within a running song keeps the values, since the channel has reached them. +Within a note, every frame the instrument writes hands its value to the channel. A silent frame sets its level alone and leaves pitch and timbre where the note last put them. + +The row's level scales the instrument's. A channel sounds their product over the full level. A pulse channel rounds it to the nearest step. The noise channel rounds it down, and sounds the quietest level wherever that comes out silent while both levels sound. FamiTracker and Bitphase set the noise level by that rule, so an exported song's noise plays there at the level it plays here. On the pulse channels the two trackers part: Bitphase rounds to the nearest step as the app does, and FamiTracker rounds down as it does on noise. + +A pass through the song begins on the same values, so starting the song and looping back to its first row sound the same. Seeking within a running song keeps the values, since the sounding note has reached them. ## Rendering the song to a file @@ -131,7 +135,8 @@ The device holds a release per stream it handed out and invokes it whenever it n | Error presentation for a source's failures | `GuardedPlayer` (`coordinators/playback/guard.py`) | | The sequencer's mute set, its mask, and solo | `SequencerChannelsLogic` (`logic/sequencer/channels.py`) | | Row mixing, and the mask it pulls while rendering | `RowSynthesizer` (`logic/sequencer/playback/synthesizer/`) | -| The values a channel holds between frames | `ChannelState` (`logic/sequencer/playback/synthesizer/state.py`) | +| 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/`) | | Rendering the song to a file, its passes and its progress | `SongRenderService` (`services/render/`) | diff --git a/docs/development/application/reconstruction-edits.md b/docs/development/application/reconstruction-edits.md new file mode 100644 index 000000000..f6ed122ef --- /dev/null +++ b/docs/development/application/reconstruction-edits.md @@ -0,0 +1,75 @@ +# Editing the Open Reconstruction + +This document describes how the Reconstructions tab changes the document it has open. It governs +`logic/reconstruction/rewrites/` and the parts of `ReconstructionCoordinator` that put a document away or +wait on its edits. Consult it when adding a gesture that edits the open document, one that reads or puts +away the whole document, or one that replaces the document from outside the tab. + +A rebuild of an edited channel runs on a worker thread, so the reader can make the next change before the +last one has landed. Every rule below keeps the document as though each change had landed before the next +one was made. + +--- + +## One step at a time, in the reader's order + +The open document changes as a line of steps: a channel the reader moved, a recording taken out, a new NES +frequency, and a gesture that reads or puts away the whole document. The line takes one step at a time, in +the order the reader asked for them, and each step is built from the document the step before it left. Two +quick edits therefore both land, whichever dimensions or channels they touch. + +## A step carries what the reader moved + +A channel change holds the dimensions the reader moved alone. At its turn it is written over the +envelopes the document holds then, and it reaches the recordings the reader hears then. The rest of the +channel is read afresh, so what a step before it rebuilt stands. A set of envelopes read from an older +document names every frame of the channel, a frame a removal has since released included, and writing it +back would make that frame the reader's own ([Stems in the application](stems.md)). + +Changes of one channel waiting next to each other merge into one, so a drag collapses into the place it +ended. A change joins a change of its own channel waiting at the end of the line. The running rebuild and +every other step keep their places, so each position the reader asked for is rebuilt or overtaken by a later +one. + +## A result lands on the document it was computed from + +A rebuild remembers the document it started from, and its result lands only while that document is open. A +change from outside the tab that replaces the document, such as a load, opening another voice, a close, an +undo reaching the open sample or a replaced sample, puts away the edits meant for the document it replaces. +The result of a rebuild still running for it is dropped when it arrives. A closed or replaced document +therefore keeps what it held, and the history stays as it was. + +## The panel draws what the document will hold + +The instruments panel reads the document's envelopes through the changes still on their way, so the bars, +the plots and the byte figures answer for the document once the line empties. A change the line lets go of, +such as a failed rebuild, redraws the panel from the document as it stands. + +## A step that no longer applies is skipped + +A step meets the document the steps before it left. A removal of a recording an earlier step already took +out, or of the last recording standing, is skipped. So is a rate the document already runs at. + +## A gesture on the whole document waits for the edits before it + +Undo, redo and a history jump, saving, loading, opening a voice, closing, exporting, adding the document to +the sequencer, the project's own save, new, open, close, export and render, a new project rate, removing, +replacing or duplicating a voice, taking an instrument from it or exporting one, and the exit all read or +put away a whole document. Each waits for the edits made before it, so an undo right after a drag undoes the +drag and a save writes it. With nothing on its way, the gesture runs at once. + +A channel change drawn while a removal or such a gesture waits is refused, since it was drawn on a view the +waiting step is about to change. The panel is redrawn once the line empties, which takes the refused change +off the screen. + +## The project rate reaches the open sample as a step + +A new project rate retunes the samples in the background, one batch for all of them. The rate waits for the +edits before it, and the sample open on the tab then takes it as a step of its own line, so the sample +joins the rate change's history entry with every other sample. A batch result for a sample edited since the +batch started is retuned again from what the sample now holds. + +## The waveform fades while the line is busy + +The waveform is faded from the moment a step waits or runs until the last one lands. A reader sees the +document is being rewritten for the whole span, however many steps it takes. diff --git a/docs/development/application/stems.md b/docs/development/application/stems.md index 957d7ecae..65496afeb 100644 --- a/docs/development/application/stems.md +++ b/docs/development/application/stems.md @@ -14,7 +14,7 @@ Opening the document loads the recorded stems through `load_recordings`, at the A document on disk is named after its file. The converter names that file from every recording it read, through the naming rules in `sampletones_core.reconstructions.naming`, so the name stands when a recording later leaves the document. A document held in memory, such as a project sample, carries the name it was given. -The Stems card names every recorded path, one row per stem. Each row has its own full-path tooltip and reveals its recording on a double-click. The Audio source panel keeps the reconstruction's own file, the choice between the two waveforms and the engine rate, which a document living on disk retimes through `set_nes_frequency` and a project sample leaves to the project. Locating reveals every recorded path at once, in one window with every stem selected where the file manager supports that, and in one window per directory otherwise. +The Stems card names every recorded path, one row per stem. Each row has its own full-path tooltip and reveals its recording on a double-click. The Audio source panel keeps the reconstruction's own file, the choice between the two waveforms and the engine rate, which a document living on disk retimes as a step of its edits and a project sample leaves to the project. Locating reveals every recorded path at once, in one window with every stem selected where the file manager supports that, and in one window per directory otherwise. ## The stems card @@ -38,7 +38,7 @@ The reconstruction tab's Stems card turns the recorded assignment into a listene A filtered mix keeps every array at its unfiltered length, so it aligns with the unfiltered one sample for sample. The filter zeroes the unselected frames per channel before mixing (`filter_approximations`), and the original mix covers the recordings of the stems heard on any channel. The panel logic holds the channels each stem is heard on and re-answers the stems view model, the waveform and the audio data whenever the choice changes. A reconstruction that records one source presents a single row for its recording, and one that records no source shows the card's empty state. -Removal runs through `without_stem`, which returns a fresh reconstruction holding what the rules in [Editing a stems reconstruction](#editing-a-stems-reconstruction) leave. The tab coordinator hands the result on as a `ReconstructionEdit`, the payload both a regenerated instrument and a removed recording travel as. One path therefore rebinds the open document and records the edit against the project history. +Removal runs through `without_stem`, which returns a fresh reconstruction holding what the rules in [Editing a stems reconstruction](#editing-a-stems-reconstruction) leave. A confirmed removal is a step of the open document's edits, taken after the edits made before it ([Editing the open reconstruction](reconstruction-edits.md)), and it lands as a `ReconstructionEdit`, the payload both a regenerated instrument and a removed recording travel as. One path therefore rebinds the open document and records the edit against the project history. They differ in what the instruments panel does. A regenerated instrument carries the envelopes the panel's own edit wrote, so the panel keeps drawing them. A removal redraws every channel from the document it leaves. ## Editing a stems reconstruction diff --git a/docs/development/application/undo.md b/docs/development/application/undo.md index fe97a4fdd..346e46e1b 100644 --- a/docs/development/application/undo.md +++ b/docs/development/application/undo.md @@ -7,9 +7,15 @@ This document describes the undo/redo subsystem of `sampletones_application`. Co ## Engine: snapshot + cursor -`HistoryManager` holds an ordered list of whole-project snapshots and a cursor. The live project always equals a restoration of `entries[cursor]`. Undo and redo move the cursor and reinstall the snapshot there. They never mutate a stored snapshot, so reversibility determinism holds by construction. A restore installs a fresh copy through `ProjectController.replace_project`, which fires `on_project_replaced` to rebuild the tabs exactly as loading a project does. +`HistoryManager` holds an ordered list of whole-project snapshots and a cursor. The live project always equals a restoration of `entries[cursor]`. Undo and redo move the cursor and reinstall the snapshot there. They never mutate a stored snapshot, so reversibility determinism holds by construction. A restore installs a fresh copy through `ProjectController.replace_project`, which fires `on_project_replaced`. The composition root owns that signal and fans it out to the two tabs that show the project: the sequencer rebuilds its views exactly as loading a project does, and the Reconstructions tab follows the voice it shows. -A snapshot (`snapshot_project`) deep-copies the light structure (song, settings, metadata, sample shells) and **shares each `Reconstruction` by reference**. Reconstruction edits are copy-on-write. `RegenerationService` emits a *new* reconstruction, and the apply path installs it via `ProjectController.replace_sample_reconstruction`. A shared reconstruction therefore never mutates in place, and snapshots never duplicate the large audio arrays. Producing the new reconstruction deep-copies the edited one once, on the regeneration worker's background thread. +A snapshot (`snapshot_project`) deep-copies the light structure (song, settings, metadata, sample shells) and **shares each `Reconstruction` by reference**. Reconstruction edits are copy-on-write. `RegenerationService` emits a *new* reconstruction, and the apply path installs it via `ProjectController.replace_sample_reconstruction`. An edit is recorded as it lands, in the order the reader made the edits, and an undo waits for the edits made before it ([Editing the open reconstruction](reconstruction-edits.md)). A shared reconstruction therefore never mutates in place, and snapshots never duplicate the large audio arrays. Producing the new reconstruction deep-copies the edited one once, on the regeneration worker's background thread. + +## The open voice across a restore + +The Reconstructions tab knows the voice it shows by its id, which a snapshot and the project file both keep. A restore therefore reaches that voice the way it reaches the sequencer. A sample the restore keeps rebinds to the reconstruction the snapshot shares and redraws, and the waveform re-fits when that reconstruction runs at another NES frequency. A kept instrument redraws its envelopes. A voice the restore takes out closes, and a redo that brings it back leaves the tab empty until the reader opens it again. + +Outside a restore, a change to the project only closes a voice that left it. That leaves the panel to the reader's own edit, which writes the project a moment before the open document takes it. A new, opened or closed project lets the voice go even where its id resolves, because a reopened file brings back the same ids for the project the reader has just put away. ## Grouping and detection diff --git a/docs/development/architecture.md b/docs/development/architecture.md index a6873e454..2bebb5883 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -130,6 +130,15 @@ An **action** is one `ShortcutId`: the name a key press, a menu item and a conte A menu item is a view of an action. `ShortcutManager.add_menu_item(shortcut_id, ...)` takes both the accelerator and the call from the action and keeps the item under it, so a rebind re-prints the key already on screen. One builder, owned by whoever owns the actions, states a set of actions that several menus show, and each menu decides where to print it. A menu whose contents follow a selection states them when it is opened. [`keyboard.md`](application/keyboard.md) covers the kinds of action that state their call differently and the mechanism behind a restated menu. +### 15. The screen holds one modal at a time + +DearPyGui shows one modal window at a time, and a modal built while another stands opens hidden. The screen +therefore belongs to one conversation at a time: a dialog, the modals it hands the screen to, and the ones +its answers raise. A modal asked for from anywhere else, such as the report of a finished job, waits in line +and opens once that conversation has ended. Every modal opens through `GUIWindow`, and one line decides when +each reaches the screen, so a caller raises a dialog the moment it has one. [Dialogs](application/dialogs.md) +describes the conversation and its hand-offs. + --- ## Enforcement @@ -212,7 +221,7 @@ Coordinators are the glue between the UI, logic and service layers. Each owns th `create_tab()` is the sole authority for the tab's layout. It declares the column and card arrangement through the shared `ui/elements/layout` primitives and injects each panel's parent container via `create_panel(parent)`. It builds widgets alone. Pushing the first view models and refreshing trees runs afterward, from the coordinator's post-build initialization, once the whole tree exists. -- A coordinator touches DPG on a narrow, closed surface: inside `create_tab()`, and when building dialog content inside a closure passed to `DialogsRenderer.show_modal`. A dialog that must wait for the next frame is deferred through `FrameCallbackManager`. All other presentation goes through `DialogsRenderer`. +- A coordinator touches DPG on a narrow, closed surface: inside `create_tab()`, and when building dialog content inside a closure passed to `DialogsRenderer.show_modal`. A dialog is raised the moment the coordinator has one, and the modal line decides when it reaches the screen (principle 15). All other presentation goes through `DialogsRenderer`. - File selection runs through OS-native dialogs, which live outside DPG. A coordinator opens one via `utils/file_dialogs`, a synchronous call that returns once the user picks a path or cancels. It resolves the dialog title and filter name from `LanguageManager`, and routes the returned path through a handler decorated with `@ignore_none_path`. Each handler body then runs with a real path, and a canceled dialog passes quietly. The backend is chosen at runtime, so a coordinator names no platform (principle 11). - A coordinator holds no domain state. It delegates reads and writes to the managers and controllers it was given. What it caches is presentation wiring: resolved language strings, panels, logic objects and callbacks. - Callbacks received from `Application` as constructor parameters are stored and forwarded as they stand. A wrapper is sanctioned only where a contract requires an intent-level guard, such as a busy-authority start-time guard (principle 10) around an operation's entry point, and the guard is all the wrapper does. A wrapper that renames a call, reorders its arguments or adds a step of its own is the coordinator taking on work that belongs to the logic object the call reaches. diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 31f8c16a1..05eb96f00 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -30,6 +30,18 @@ dimension the import starts carrying. their own, and scheme needs the item bit-packing FamiTracker gives it. * The sample column reads the sample still playing from the top of each frame, so a frame's first rows take no transpose or volume while playback still carries the previous frame's sample. +* A tracker export moves a whole contour by one written note, so where a transpose carries part of it + outside pitches 33–119, the ticks in-app playback clamps sound unclamped in the tracker. A clamped table + (Bitphase) or arpeggio (FamiTracker) per such transposition would make them exact. +* 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. ### Workflow @@ -135,6 +147,3 @@ currently out of line. An entry leaves when the code meets the contract again. moved. ## Bugs - -* Removing a recording leaves the instruments panel drawing the envelopes it showed before. The removal - reaches the panel through `refresh_view`, which redraws only a channel an edit silenced. diff --git a/docs/development/documentation.md b/docs/development/documentation.md index a2783681a..877e16484 100644 --- a/docs/development/documentation.md +++ b/docs/development/documentation.md @@ -35,6 +35,8 @@ Use plain, direct English. Short sentences, one fact each. Everyday verbs, not t vocabulary. A term the reader would not know is either avoided or defined in [the glossary](../glossary.md) and linked from the page that uses it. +**Apply one test to every sentence: if the reader would know it without being told, it goes.** That covers what a button labeled Cancel does, what the screen already shows, and what the application does by itself. A change to the application earns a sentence in the guide only when the reader needs that sentence to act. Decide this for every change, and expect the answer to often be none. + Two passages read the way a guide page should. "Voices: samples and instruments" in [the sequencer guide](../guide/sequencer.md) names each kind in one sentence, then says what the reader does with it. The three install options at the top of [installation](../guide/installation.md) give each diff --git a/docs/development/guidelines.md b/docs/development/guidelines.md index 7f7b5e821..7df38780e 100644 --- a/docs/development/guidelines.md +++ b/docs/development/guidelines.md @@ -64,6 +64,12 @@ These rules govern the Python in this repository. They complement 1. Freeze models whose instances stay constant after construction (`frozen=True`). 1. Use a dataclass for a small internal state object that needs neither serialization nor validation, test-case dataclasses included. +## Messages + +1. Write every message the application shows a user in plain American English: labels, dialogs, status lines and the notices an export adds. Log lines and exception messages are written for developers and may stay technical. +1. Write for a regular user, not a developer. Name what the user sees and does in everyday words, and leave the repository's own vocabulary out. Keep sentences short, one fact each. Every document outside `docs/development/` has the same reader (see [writing the documentation](documentation.md)). +1. Keep every message in the language file, `src/sampletones_config/lang/en.yaml`, and read it by its key where it is shown ([principle 8](architecture.md#8-all-display-text-comes-from-languagemanager)). A translation then replaces the file alone. The `language-keys` hook holds every key the code spells to an entry, and every entry to a reader. + ## Docstrings and Comments 1. A docstring explains the intention of a class or function and the context of its use. diff --git a/docs/development/packages.md b/docs/development/packages.md index 9778368e3..a06c36917 100644 --- a/docs/development/packages.md +++ b/docs/development/packages.md @@ -48,7 +48,7 @@ graph TD | `sampletones_core` | The reconstruction engine, the project model, playing a song out into instructions, and the tracker export formats | | `sampletones_player` | The NES player: the register model, the re-clocking schedule, the 6502 driver and the NSF file | | `sampletones_application` | The DearPyGui front end | -| `sampletones_tools` | Everything a developer runs and the application does not: the calibration harness, the driver toolchain and the register trace, the source checks, the synthetic corpus, and the developer commands that run them | +| `sampletones_tools` | Everything a developer runs and the application does not: the calibration harness, the driver toolchain and the register trace, the source checks, the synthetic corpus, the tracker playback check, and the developer commands that run them | | `sampletones` | The command-line entry: the dispatcher, the commands and the startup self-check | **Only the command line reaches the tools package.** `sampletones_tools` has what a developer runs and the diff --git a/docs/development/tooling.md b/docs/development/tooling.md index 6a612e313..5e6ced4d5 100644 --- a/docs/development/tooling.md +++ b/docs/development/tooling.md @@ -63,8 +63,9 @@ 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. `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 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. **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 1b4abfe32..77341648b 100644 --- a/docs/formats/bitphase.md +++ b/docs/formats/bitphase.md @@ -4,6 +4,8 @@ This document is the reference for how _SampleToNES_ writes [Bitphase](https://g files: the `.btp` document and the `.json` instrument preset. It also lists the Bitphase capacity limits the exporter respects. Read it before changing anything under `formats/bitphase/`. The sibling [FamiTracker export](famitracker.md) document covers the other tracker. +[The tracker playback check](../tools/tracker-playback.md) plays exported documents through Bitphase's +own engine and lists every tick they sound differently from the app. The target is Bitphase's **NES (2A03) chip**: five channels (two squares, triangle, noise, DPCM). The DPCM channel is always silent. Every constant named here has a counterpart under @@ -69,7 +71,7 @@ only for the fields a reconstruction decides. The fields match Bitphase's own: | Field | Range | Default | Runtime meaning | What the exporter writes | | --- | --- | --- | --- | --- | | `volumeOrRate` | 0–15 | 15 | the literal channel volume while `envelope` stays off | the volume envelope, or one full level where the slice leaves its volume to the channel | -| `pulseWidth` | 0–3 | 2 | square duty cycle; on the noise channel, any nonzero value selects the short LFSR | the duty-cycle envelope (squares), the short or long mode (noise); the triangle writes no macro | +| `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 | | `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 | @@ -95,10 +97,17 @@ instrument states. The envelopes are one set for every channel, so the slices di channel reads of them. **A held volume.** A slice whose volume envelope has no item leaves its level to the channel. The exporter -writes one full `volumeOrRate`. Playback combines that level with the pattern's volume column through a -PT3 volume table, where a full level comes out at the column's own level. The slice therefore sounds at -whatever level the channel has, which is how FamiTracker reads a disabled volume sequence. A slice that -describes no frame at all writes a single silent value, the smallest instrument Bitphase plays. +writes one full `volumeOrRate`. Playback combines that level with the pattern's volume column, and a full +level comes out at the column's own level (section E). The slice therefore sounds at whatever level the +channel has, which is how FamiTracker reads a disabled volume sequence. A slice that describes no frame at +all writes a single silent value, the smallest instrument Bitphase plays. + +**Every note starts where a song does.** Bitphase reads every field from the instrument a note plays, +from its first tick, and every note cell the exporter writes names its slice's own table. A slice that +leaves a dimension to the channel writes the value _SampleToNES_ starts a note on: a full level, a flat +table, no tone offset, and a `0` pulse width, which is the long mode on noise. The pulse width needs its +macro, because a field with none takes Bitphase's default of `2`. The note therefore sounds the same +whatever played before it, as it does in the app and in FamiTracker. **A table runs beside the macros.** The table advances one step per tick on a counter of its own, so the contour keeps the length and the repeat point the arpeggio envelope was written at, whatever the macros @@ -129,9 +138,11 @@ takes the tuning its samples were reconstructed at, and a project with no sample table sounds one tuning, so a project whose samples were reconstructed at different tunings is refused, as the [NSF export](../development/player.md#the-song-a-file-carries) refuses it. -**A note index is the absolute pitch less 24.** Indices 0–95 cover pitches 24–119, the span the FamiTracker -exporter clamps to. A pattern cell stores the index as a semitone and an octave, which playback resolves -back with `name - 2 + (octave - 1) * 12`. +**A note index is the absolute pitch less 24.** Indices 0–95 cover pitches 24–119, the span a FamiTracker +note cell covers too. A pattern cell stores the index as a semitone and an octave, which playback resolves +back with `name - 2 + (octave - 1) * 12`. At concert pitch the nine indices below pitch 33 all resolve to +the longest period, 2047, about 11 cents flat of A1. _SampleToNES_ plays pitches 33–119, so section E +writes a note to keep within them. The triangle channel's period comes from the same table, so a written note sounds an octave below. _SampleToNES_ and FamiTracker share that convention. @@ -147,17 +158,20 @@ each step to the one before it; a contour measures every step from the note, so A pattern's `table` column names a table by `id + 1`. `0` leaves the attached table alone and `-1` detaches it. -**Noise** derives its period from the note index, not from the tuning table: playback reads -`period = 15 - (index mod 16)`. Every period therefore repeats once per sixteen indices. The exporter picks -a base index far enough below the top of the table for a whole cycle of offsets to stay in range: +**Noise** derives its period from the note index, not from the tuning table: playback writes +`15 - (index mod 16)` to the period register, whose timers run fastest first. _SampleToNES_ counts its +sixteen periods from the slowest, so a period `p` reaches the register as `15 - p`, which is what the NSF +player writes too. The note index therefore carries the period itself. Every period repeats once per +sixteen indices, and the exporter picks a base index far enough below the top of the table for a whole +cycle of offsets to stay in range: ``` -base index = 48 + ((15 - initial_period) mod 16) lands in 48..63 -table offset = (-arpeggio_step) mod 16 lands in 0..15 +base index = 48 + (initial_period mod 16) lands in 48..63 +table offset = arpeggio_step mod 16 lands in 0..15 ``` -So `15 - ((base + offset) mod 16)` is the period the reconstruction chose, wrapped into the sixteen the -channel has. +So `(base + offset) mod 16` is the period the reconstruction chose, wrapped into the sixteen the channel +has, and the register holds `15` less that period. ### C.3 Presets fold the contour into the period @@ -242,8 +256,69 @@ of its own with that frame's channels side by side, and `patternOrder = [0..n-1] over whole and shares fewer patterns. Row cells follow from the columns. An instrument command writes the note from `initial_pitch + transpose`, -the instrument number, the table column and the row's volume. A note-off writes note name `1`. A blank -line leaves every column alone. +the instrument number, the table column and the volume column below. A note-off writes note name `1`. A +blank line leaves every column alone. + +**The note keeps the song's pitch range.** In-app playback holds every tick's transposed pitch within +33–119. Playback moves the written note by the table's step each tick and holds the result at index 95 +(pitch 119), so a note up to 119 is written as it is and a higher one is written at 119. A note below 33 +is raised only as far as bringing the table's highest step to 33: + +| Row | What the exporter writes | +| --- | --- | +| a flat slice transposed below 33 | pitch 33, the note in-app playback sounds | +| a contour reaching 33 on some ticks | the transposed note, so every tick in-app playback sounds at 33 or above keeps its note | +| a contour lying wholly below 33 | the note whose highest step lands on 33 | + +A tick that in-app playback holds at 33 while its step moves the written note lower plays the period +that lower index resolves to, which at concert pitch is the longest (section C.1). + +**A transpose row moves the note sounding.** In-app playback reads a row that states a transpose and no +note as a new pitch for the note already sounding: the voice goes on from the tick it reached, and every +tick from that row on sounds at the new transpose. A note-on starts over at its own transpose. Bitphase +restarts the instrument on a note or an instrument cell, and a table cell alone attaches a table at its +first step while the instrument plays on. So the row writes a table cell and an effect: + +| Column | What the exporter writes | +| --- | --- | +| Table | a copy of the note's table with every step moved by `N`, the distance from the note already written to the note a note-on at the new transpose would write | +| Effect | `5` (ornament position), `delay = 0`, the parameter naming the step the note's table has reached, which the first step leaves out | +| Note, instrument | empty, so the instrument goes on | + +`N` is measured from the note the note-on wrote, whatever an earlier row moved the note to, so each row's +pitch is the transpose it states. From that tick the channel sounds what a note-on at the new transpose +would sound, the low-note rule above included. On noise `N` is measured the same way, between the two +note indices the period mapping of section C.2 writes, so the moved table walks the period around the +sixteen the channel has and follows that mapping wherever it places a period. A row returning to the +note's own transpose names the note's own table, a row keeping the transpose in force writes nothing, +and rows moving one slice by one distance share one table. + +The note's table advances a step per tick from the note on, so the step the effect names counts the ticks +every row since the note lasts, the groove's uneven rows and the frames between included. The effect +reads its parameter as a byte, so it names steps 0–255. A row reaching a later step names a copy that +opens on that step: its steps run from there and circle over the steps the note's table circles +over, and the row writes no effect. + +A transpose row reached while no note sounds moves nothing and writes nothing, and neither does one +following a note-on that was written as a note cut. + +**A note starts at the full level.** In-app playback starts a note whose row states no volume at the full +level. Playback carries the level a channel last took into every note after it. Such a note therefore +writes `15` wherever playback reaches it carrying another level, and keeps an empty cell where the +channel stands at the full level already. The exporter follows each channel's level through the order the way +Bitphase plays it: frame by frame, then from the loop point, the first frame, with the level the order +ended on. A note-on written as a note cut takes the same level. + +**The column scales the instrument's level.** Playback combines the two levels the way in-app playback +does. On the pulse channels it reads a PT3 volume table, which rounds their product over the full level to +the nearest step. On noise it rounds the product down, and plays the quietest level wherever that comes +out silent while both levels sound. Either way a full instrument level plays at the column's own level. + +**The triangle sounds above half volume.** In-app playback sounds the triangle while a row's volume is 8–15 +and silences it at 0–7. Playback enables the triangle while the PT3 product of the pattern level and the +instrument level is above zero, and a triangle slice writes a full instrument level, so any pattern level +above zero sounds it. A triangle row at 0–7 therefore writes the silencing `-1`, and a row at 8–15 writes +its level. **The volume column names silence.** In Bitphase you type `0` to silence a channel and leave the cell blank to carry its level forward. The file stores those two as `-1` and `0`. The volume field is declared @@ -258,12 +333,13 @@ is the same cell you would see in the tracker. | --- | --- | --- | | 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, which the groove trigger takes on the DPCM channel | +| Effect columns per channel | 1–4 | writes one: the groove trigger 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, one of which a groove takes | -| Note range | the 96-entry tuning table, pitch 24–119 | clamps to the nearest playable note | +| 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 | +| 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 | -| Volume column | `-1` silences (the tracker shows `0`), `0` carries the level forward (shown blank), 1–15 set the level | writes the row's level, and `-1` where a row asks for silence | +| Volume column | `-1` silences (the tracker shows `0`), `0` carries the level forward (shown blank), 1–15 set the level | writes the row's level, `15` on a note the channel reaches at another level, and `-1` where a row asks for silence or the triangle's level is 0–7 | | 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 | @@ -276,7 +352,9 @@ 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. +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. **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 9eacc7e8f..36307c7e2 100644 --- a/docs/formats/famitracker.md +++ b/docs/formats/famitracker.md @@ -156,7 +156,7 @@ Instruments reference the pooled sequences by index, so the module stores each s | instrument | `int8` | per stored row | | volume | `int8` | per stored row | | effect | `int8` | per stored row, one for each effect column | -| effect parameter | `int8` | per stored row, one for each effect column | +| effect parameter | `uint8` | per stored row, one for each effect column | **Pattern cell encoding** @@ -166,7 +166,8 @@ Instruments reference the pooled sequences by index, so the module stores each s | octave | `0`–`7` | | instrument | `0x40` when empty | | volume | `0x10` when empty | -| effect | `0` when empty | +| effect | `0` when empty, `20` for `Qxy` (note slide up), `21` for `Rxy` (note slide down) | +| effect parameter | for `Qxy` and `Rxy`, the speed `x` in the high four bits and the semitones `y` in the low four | A pitch converts to a cell by `note = pitch % 12 + 1` and `octave = pitch // 12 − 2`. This matches `pitch_to_name` in `sampletones_core/utils/frequencies.py`. @@ -236,8 +237,11 @@ export](bitphase.md#f-bitphase-capacity-limits) shortens a dimension by the same **Empty dimensions.** An empty dimension is written as a disabled sequence. This differs from a sequence with a single zero: a disabled slot leaves that dimension to the channel, while a one-item sequence sets -the value once and holds it. 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 +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 +the channel governs. Clearing the envelope in the instruments panel produces that state (see [Reconstructions](reconstructions.md)). **How _SampleToNES_ fills an instrument.** Each channel slice of a sample's reconstruction becomes one @@ -259,9 +263,9 @@ instrument reaches that one instrument, each against the initial pitch it reads: channels, its period on noise. **Where a row's note comes from.** A voice has a reference, the place where its zero is, and a row has a -step from it. A pattern cell therefore holds `reference + transpose`, kept inside the range a tonal -channel plays and wrapped into the sixteen periods on noise. A sample's reference is the offset origin its -conversion chose. A hand-written instrument's reference is its initial pitch. +step from it. A pattern cell therefore holds `reference + transpose`, wrapped into the sixteen periods on +noise. A sample's reference is the offset origin its conversion chose. A hand-written instrument's +reference is its initial pitch. The conversion chooses the origin once, when the reconstruction is built, and stores it as that channel's reference pitch (see [Reconstructions](reconstructions.md#contents)). For the pitched channels @@ -271,6 +275,68 @@ each frame as `pitch − initial_pitch`, wrapped into the 16 available periods o straddle zero and stay compact around one note. The pattern cell holds the contour's midpoint, so a rising contour prints its middle note and opens below it. +On the tonal channels the note keeps the song's pitch range. In-app playback holds every tick's +transposed pitch within 33–119 (A-0 to B-7). FamiTracker moves the written note by the arpeggio's item +each tick and clamps the result to C-0..B-7 (`CChannelHandler::TriggerNote`), so a note up to 119 is +written as it is and a higher one is written at B-7. A note below A-0 sounds below every pitch in-app +playback plays: on NTSC each one plays the longest period, `0x7FF`, about 12 cents flat of A-0, and on +PAL they sound one semitone or more below A-0. A note below 33 is therefore raised only as far as bringing +the arpeggio's highest item to 33: + +| Row | What the exporter writes | +| --- | --- | +| a flat instrument transposed below 33 | A-0, the note in-app playback sounds | +| an arpeggio reaching 33 on some ticks | the transposed note, so every tick in-app playback sounds at 33 or above keeps its note | +| an arpeggio lying wholly below 33 | the note whose highest item lands on A-0 | + +A tick that in-app playback holds at A-0 while its item moves the written note lower plays that lower +note. + +**A transpose row slides the note sounding.** In-app playback reads a row that states a transpose and no +note as a new pitch for the note already sounding: the voice goes on from the tick it reached, and every +tick from that row on sounds at the new transpose. A note-on starts over at its own transpose. `Qxy` and +`Rxy` move the channel's note by `y` semitones at once (`CChannelHandler::SetupSlide`) and glide the +period toward it at `2x + 1` units a tick. While an instrument's arpeggio in absolute mode runs, it +reloads the period from the note every tick (`CSeqInstHandler::ProcessSequence`), which makes the move +instant. So a transpose row writes: + +| Column | What the exporter writes | +| --- | --- | +| Effect | `Qxy` or `Rxy` at the highest speed, `x = F`, sliding from the note the channel holds to the note a note-on at the new transpose would write | +| Note, instrument | empty, so the instrument goes on | + +Each slide is measured from the note the channel holds once the slides before it applied, so a note's +rows never drift from the transposes they state, and the low-note rule above holds for the note a slide +reaches. On noise the note is the period, and both notes lie within the sixteen periods, so every noise +slide is within reach. A row keeping the note where it stands writes no slide. + +A halted sequence reloads nothing, and the glide is then heard instead. Every instrument a slide reaches +therefore keeps its arpeggio running: an arpeggio playing its items once circles on its last item, and an +instrument writing none takes one item at its own note, repeating. A bend is added to the period the +arpeggio reloads, and a halted bend adds nothing, so a bend playing its items once circles on its last +item too and holds the offset it ends on. An instrument no slide reaches keeps its sequences as they +are, so a module without transpose rows is unchanged. + +A slide moves the note by fifteen semitones at most, and a module stores a pattern once for every frame +that plays it. A row moving the note further, or a cell of a shared pattern that another frame reaches +needing a different slide, is written without its slide there. The first frame reaching a cell with a +note sounding decides its slide. The export reports each such row by its frame, channel and row, beside +the rows written as note cuts, and the rows after it slide from the note the channel holds. + +**What a row's volume cell holds.** A row naming a volume writes it. In-app playback starts a note whose +row states no volume at the full level, while FamiTracker carries the level a channel last took into every +note after it. Such a note therefore writes `15` wherever FamiTracker reaches it carrying another level, +and keeps an empty cell where the channel stands at the full level already. The exporter follows each +channel's level through the order the way FamiTracker plays it: frame by frame, then from the first frame +again with the level the order ended on. A pattern is stored once for every frame that plays it, so its +note writes `15` when any of those frames reaches it at another level. A note-on written as a note cut +takes the same level. + +The triangle sounds in-app while a row's volume is 8–15 and falls silent at 0–7. FamiTracker sounds the +triangle while both its instrument volume and its volume column are above zero +(`CTriangleChan::RefreshChannel`). A triangle row at 0–7 therefore writes `0`, and a row at 8–15 writes +its level. + ## C. Reading an instrument file An `.fti` is read as well as written. **Import instrument...** in the sequencer brings one into the voice @@ -317,7 +383,8 @@ that ends its note. | Patterns per channel | 128 (indices 0–127) | pool keyed by arbitrary ints | raises when a pattern index exceeds 127 | | Order frames | 128 | unbounded | raises when the order exceeds 128 frames | | Pattern length (rows) | 256 | 1–256 (`rows_per_pattern`) | matches; no guard needed | -| Note range | C-0..B-7 (pitch 24–119) | a reference of 33–119 plus a transpose reaching either end of that span | clamps to the nearest playable note (fidelity loss at the extremes) | +| Note slide | 15 semitones per row (`Qxy`, `Rxy`) | a transpose of −86..86 | writes the slide a transpose row needs, and reports a row needing more, or a shared pattern's cell needing different slides in different frames (section B) | +| Note range | C-0..B-7 (pitch 24–119) | a reference of 33–119 plus a transpose reaching either end of that span | keeps the song's range, A-0..B-7, raising a lower note only as far as its arpeggio's highest item reaching A-0 (section B) | | Title / author | 32 bytes each | 64 characters | truncates to 32 bytes | | Comment | free text (COMMENTS block) | 65536 characters | carried in full | | Tempo / speed | engine-dependent (split at row `speed_split_point`) | tempo 32–255, speed 1–31 | written verbatim from settings | @@ -325,7 +392,7 @@ that ends its note. 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 -dialog lists those rows. +dialog lists those rows, and the transpose rows written without their slide under a heading of their own. The exporter also reserves an empty pattern index per channel (`max used index + 1`) for order slots the song leaves unset. A channel that already fills indices up to 127 leaves no room for it, and the exporter @@ -371,4 +438,5 @@ appears once, so its own sequences are charged once each. **Length.** A sequence is written at the length it holds (section B), so a figure counts each dimension as it stands. A loop point on one dimension adds a byte and no padding. The figure for a voice is therefore -what its **Export instrument...** writes. +what its **Export instrument...** writes. In a module, an instrument a note slide reaches and that writes +no arpeggio takes a one-item arpeggio of its own (section B), which adds a sequence to its figure. diff --git a/docs/formats/reconstructions.md b/docs/formats/reconstructions.md index 545ab3283..164d97831 100644 --- a/docs/formats/reconstructions.md +++ b/docs/formats/reconstructions.md @@ -31,7 +31,7 @@ One entry per channel: | `channel_name` | `pulse1`, `pulse2`, `triangle` or `noise` | | `instructions` | the stream the channel plays, one [instruction](../glossary.md#instruction) per frame. A FamiTracker export is built from this | | `initial_pitch` | the note the channel's arpeggio offsets are measured against, chosen when the reconstruction is built. An export reads the offsets against this pitch, so editing an arpeggio moves the frames around a fixed base (see [FamiTracker export](famitracker.md)) | -| `held_features` | the dimensions the channel governs. The instrument writes the others itself. An export leaves the governed dimensions empty, and the player keeps the value it already has for them | +| `held_features` | the dimensions the channel governs. The instrument writes the others itself. An export leaves the governed dimensions empty, and every note sounds them at the value a song starts on | A stream of no frames is a channel **standing by**. A channel whose every frame rests stands by, whatever silenced it, so such a stream is stored as no frames at all. No export writes it and it costs nothing, and diff --git a/docs/guide/command-line.md b/docs/guide/command-line.md index 465f0ded9..4db894173 100644 --- a/docs/guide/command-line.md +++ b/docs/guide/command-line.md @@ -35,6 +35,10 @@ 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. + ## Options - `--config ` or `-c ` uses a configuration file. It works with `run`, `open`, diff --git a/docs/guide/converting.md b/docs/guide/converting.md index de5dc3ae9..fca5ce4d4 100644 --- a/docs/guide/converting.md +++ b/docs/guide/converting.md @@ -100,6 +100,11 @@ When the conversion finishes, click **Load** to open the result on the **Reconst you can [listen to it and export it](reconstruction.md). After a conversion of several recordings, the button opens the folder instead. +If the reconstruction you have open has unsaved changes, **Load** asks whether to save them first. If +the conversion wrote over that same reconstruction, you can't save your changes there. **Discard** +loads the new one and throws your changes away. To keep them, click **Cancel** and use **Save +reconstruction as...** to save them to another file. + The first conversion with new settings takes longer, because it builds the [instruction library](../concepts/instruction-library.md) for them. Later conversions with the same settings reuse it. diff --git a/docs/guide/reconstruction.md b/docs/guide/reconstruction.md index 167d2065a..0bbdfefaa 100644 --- a/docs/guide/reconstruction.md +++ b/docs/guide/reconstruction.md @@ -16,7 +16,8 @@ To keep frequently used reconstructions within reach, right-click a reconstructi choose **Mark as favorite**. Check **Favorites only** to show only those items. If the reconstruction you have open has unsaved changes, opening another one asks whether to save it -first. +first. **Save** saves your changes and then opens the other one. **Discard** opens it and throws your +changes away. **Cancel** keeps the one you have open. The **Source** card switches playback between **Reconstruction** and **Original audio**, so you can compare the two. Its **NES frequency** field retimes the reconstruction: type a new rate and press @@ -77,9 +78,10 @@ the attack once and then loops the last two values. A sequence turns the warning color when it is too long for an export. Point at it, and the status bar says how many values each export keeps. -Clear a sequence to use the channel's own setting. For example, an instrument with an empty volume -sequence plays at the volume the channel is set to. Each channel shows how many bytes its instrument -takes on the NES, so you can see how much space an edit uses. +Clear a sequence to play its default on every note. An empty volume sequence plays at the volume the +pattern sets. An empty arpeggio, pitch or hi-pitch sequence keeps the note where the pattern puts it. An +empty duty cycle sequence plays duty 0 on a pulse channel and the long mode on noise. Each channel shows +how many bytes its instrument takes on the NES, so you can see how much space an edit uses. You can edit an [**instrument**](../glossary.md#instrument) here as well. It is a voice you write by hand, described in the [sequencer guide](sequencer.md#voices-samples-and-instruments). Right-click one diff --git a/docs/guide/sequencer.md b/docs/guide/sequencer.md index dbcd503e8..ecf7d489b 100644 --- a/docs/guide/sequencer.md +++ b/docs/guide/sequencer.md @@ -214,6 +214,8 @@ You can undo every change, including a copy, a paste or a delete, which each cou **History** panel lists your changes, and you can click one to go back to that point. **Undo** and **Redo** are on the **Edit** menu. +Undo also covers the changes you make to a voice on the **Reconstruction** tab. + ## Timing and properties **Module options** sets the song's timing: **Rows** per pattern, **Speed** (the number of [ticks](../glossary.md#tick) @@ -244,9 +246,13 @@ rows. - **NSF program...** saves an `.nsf` file, which the NES or an NSF player plays directly. A voice plays on the channels its instruments cover. Where a row names a voice on another channel, the -FamiTracker and Bitphase files hold a note cut on that row, which is how the song plays it. The dialog +FamiTracker and Bitphase files have a note cut on that row, which is how the song plays it. The dialog that announces the export lists those rows by frame, channel and row. +A row that changes only the transpose of a playing note moves that note in both files. A FamiTracker +file moves a note by up to 15 semitones in one row. The dialog lists the rows it could not move, and the +file plays them at the old pitch. + FamiTracker and Bitphase files have room for a limited number of values per sequence. A longer sequence is cut short, and the dialog says how many instruments were shortened. diff --git a/docs/index.md b/docs/index.md index 2f1862029..cba20f4e7 100644 --- a/docs/index.md +++ b/docs/index.md @@ -40,6 +40,7 @@ The [**tools**](tools/) section covers the commands that measure _SampleToNES_ o Each page says how to run the command with no options, what it writes and every custom use. - [Calibration](tools/calibration.md) — how well the reconstruction reproduces reference sounds, with every reconstruction written out to listen to. +- [Tracker playback check](tools/tracker-playback.md) — whether a song exported to a tracker plays there the way the app plays it, tick by tick. ## File formats @@ -80,10 +81,11 @@ whole repository. The pages about the graphical application and about releases e - [Identifier vocabularies](development/application/vocabularies.md) — the keys display text is looked up by, and the tags DearPyGui knows a widget by. - [Colors and palettes](development/application/palette.md) — how a color is written, composed, and handed to DearPyGui. - [The render thread](development/application/render-thread.md) — how work reaches DearPyGui from another thread, and what each crossing costs. -- [Dialogs](development/application/dialogs.md) — how a dialog gets its size, and where it opens. +- [Dialogs](development/application/dialogs.md) — how a dialog gets its size, where it opens, and when it reaches the screen. - [Playback](development/application/playback.md) — the audio transport shared by every view, and rendering the song to a file. - [Reconstruction browser](development/application/browser.md) — how a reconstructions directory becomes the tree both browser tabs show, and what narrows it. - [Stems in the application](development/application/stems.md) — the Stems card, and what an edit or a removal does to the per-frame record. +- [Editing the open reconstruction](development/application/reconstruction-edits.md) — how the Reconstructions tab takes the reader's edits one step at a time, and what waits for them. - [Configuration](development/application/config-organization.md) — how the YAML configuration package is laid out. ### Releases diff --git a/docs/tools/tracker-playback.md b/docs/tools/tracker-playback.md new file mode 100644 index 000000000..9a2a82c19 --- /dev/null +++ b/docs/tools/tracker-playback.md @@ -0,0 +1,116 @@ +# 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. + +Each tracker the check plays through is a *target*. It has one today: + +- `bitphase` exports a `.btp` document and plays it with the engine of a copy of the + [Bitphase](../glossary.md#bitphase) source code. + +Use it to: + +- 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. + +## What it needs + +For the `bitphase` target: + +- [Node.js](https://nodejs.org), so that the `node` program runs in a terminal. +- A copy of the Bitphase source code with its packages installed. Clone + `https://github.com/paator/bitphase` and run `pnpm install` in that folder. + +The check reads that copy and leaves it as it is. + +## Run it + +From a copy of the source code: + +``` +make tracker-playback BITPHASE=path/to/bitphase +``` + +In an installed copy: + +``` +sampletones tracker-playback bitphase --checkout path/to/bitphase +``` + +With no other options, 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, + noise at several periods in both modes, a tempo whose rows last unequal ticks, an order that revisits + patterns, notes pushed below the lowest pitch, and a slice longer than a tracker instrument holds. +- The arrangement the example commands write, rebuilt from rendered sounds, at its own tempo and at a + faster one. + +The results go into a new folder inside the `tracker-playback` folder of your +[SampleToNES folder](../guide/files.md). The folder is named by the date and time the run started, for +example `run-20260930-124501`. When the run ends, the command prints each project's verdict and a link +to the report. + +## What a run writes + +| Path | Contents | +|---|---| +| `report.md` | the verdict for each project, and a table of every difference | +| `documents/` | each project as the export wrote it, and what the tracker's playback recorded of it | + +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. + +## 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: + +- The channel. +- What differs. `audible` means the channel sounds on one side only. Otherwise the difference is in + the `period`, the `volume`, or the `timbre`, which is the duty cycle on a pulse channel and the + short mode on the noise channel. +- The first tick it shows on, with its frame and row. +- What each side sounds there. +- How many ticks show it. + +Ticks that differ in the same way on the same channel are counted together. The lines under a +difference show later rows where both sides sound new values, so two causes that differ in the same +field show apart. The packaged settings set how many of those lines a difference shows. + +A sound that ends in `counted down` is one the chip's own envelope or counters move on from the +level its registers set. The chip holds every other sound where its registers put it. The two differ +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. + +## Options + +- `bitphase --checkout `: the copy of the Bitphase source code. The `bitphase` target needs it. +- `--output ` or `-o `: where the run writes. Without it, the run writes into the + timestamped folder described above. + +## How it works + +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. + +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 +the value each register holds on each tick, and reads from those values what the chip plays on each +channel: whether it sounds, its timer or noise period, its volume, and its duty cycle or noise mode. +The check reads what the tracker writes to the chip, so it sees the timer the chip receives, however +the tracker works it out from a note. + +Tick 0 is the song's first tick on both sides, so the rows of a groove line up by themselves. + +You run the check by hand. The test suite checks the corpus, the comparison and the report on their +own, and a tracker plays the files only when you run the command. diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index cc8d4bdbe..7b471834d 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -61,9 +61,14 @@ from sampletones_application.logic.reconstruction.edit import ( ChannelEdit, ReconstructionEdit, + Retune, StemRemoval, ) from sampletones_application.logic.reconstruction.manager import ReconstructionManager +from sampletones_application.logic.reconstruction.rewrites.queue import ( + ReconstructionRewrites, +) +from sampletones_application.logic.reconstruction.rewrites.steps import RateChange from sampletones_application.logic.render import SongRenderLogic from sampletones_application.parameters import ( InstructionsTabParameters, @@ -97,7 +102,6 @@ from sampletones_application.shell import ApplicationShell, ShortcutBindings from sampletones_application.tags.general import ( TAG_GLOBAL_DIALOG_ABOUT, - TAG_GLOBAL_DIALOG_EXIT_CONFIRMATION, TAG_GLOBAL_TEXTURE_LOGO, TAG_GLOBAL_THEME_DEFAULT, TAG_GLOBAL_THEME_MENU_FPS, @@ -128,6 +132,7 @@ from sampletones_application.ui.panels.dialogs.stem_selection import GUIStemSelectionWindow from sampletones_application.ui.themes.registry import ThemeRegistry from sampletones_application.ui.themes.setup import setup_themes +from sampletones_application.utils.callbacks.gates import gated, pass_gates from sampletones_application.utils.callbacks.queue import CallbackQueue from sampletones_application.utils.file_dialogs.api import ( open_file_dialog, @@ -168,14 +173,11 @@ from sampletones_core.audio import AudioDeviceManager from sampletones_core.constants.audio import BufferSize, SampleRate from sampletones_core.constants.enums import ChannelName, FeatureKey -from sampletones_core.exporters import Features from sampletones_core.exports.backend import ExportBackend from sampletones_core.exports.format import ExportFormat from sampletones_core.exports.stage import ExportStage -from sampletones_core.project.voices.instrument import Instrument from sampletones_core.project.voices.sample import Sample from sampletones_core.project.voices.voice import samples -from sampletones_core.reconstructions import Reconstruction from sampletones_core.structures.tree import FileSystemNode from sampletones_player.export.backend import NSFBackend from sampletones_shared.application import ( @@ -263,6 +265,10 @@ def __init__( _priority = self.layout.behavior.scheduling.priorities.schedule self.conversion_service: ConversionService = ConversionService(priority=_priority) self.regeneration_service: RegenerationService = RegenerationService(priority=_priority) + self.reconstruction_rewrites: ReconstructionRewrites = ReconstructionRewrites( + self.reconstruction_manager, + self.regeneration_service, + ) self.export_service: ExportService = ExportService(priority=_priority) self.export_service.subscribe(self._on_export_activity) self.render_service: SongRenderService = SongRenderService(priority=_priority) @@ -282,6 +288,7 @@ def __init__( ) self.project_controller.on_mutation = self.history.handle_mutation self.project_controller.on_saved = self.history.mark_saved + self.project_controller.on_project_replaced = self._on_project_replaced self.history.on_history_changed = self._on_history_changed self.fps_timer: FPSTimer = FPSTimer(interval=self.layout.behavior.ui.fps_update_interval) @@ -448,14 +455,15 @@ def __init__( self._reconstruction_coordinator = ReconstructionCoordinator( self.reconstruction_manager, self.session_manager, - self.regeneration_service, + self.reconstruction_rewrites, self.audio_device_manager, + self.project_controller, + self.history, dialogs=self.dialogs, language_manager=self.language_manager, on_tab_switch=self._set_current_tab, on_session_state_changed=self._on_reconstruction_state_changed, on_reconstruction_updated=self._on_reconstruction_updated, - is_reconstruction_embedded=self._editing_project_sample, ) self._original_audio_locator = OriginalAudioLocator( @@ -483,11 +491,15 @@ def __init__( export_service=self.export_service, export_backends=self.export_backends, format_setups=self._format_setups, - on_load_reconstruction_with_confirmation=self._reconstruction_coordinator.load_with_confirmation, + on_load_reconstruction_with_confirmation=gated( + self._reconstruction_coordinator.after_edits, + self._reconstruction_coordinator.load_with_confirmation, + ), on_change_audio_state=self._update_menu, on_favorite_changed=self._repaint_reconstruction_favorites, - on_reconstruction_instrument_updated=self._regenerate_instrument, - on_reconstruction_stem_removed=self._reconstruction_coordinator.apply_edit, + on_rewrite_requested=self._reconstruction_coordinator.request_rewrite, + pending_changes=self.reconstruction_rewrites, + after_edits=self._reconstruction_coordinator.after_edits, original_audio_locator=self._original_audio_locator, instrument_exports=self._instrument_exports, history=self.history, @@ -528,9 +540,15 @@ def __init__( on_busy_state_changed=self._refresh_busy_state, on_reconstruct_file=self._reconstruct_file, on_reconstruct_directory=self._reconstruct_directory, - on_load_reconstruction=self._reconstruction_coordinator.load_with_confirmation, + on_load_reconstruction=gated( + self._reconstruction_coordinator.after_edits, + self._reconstruction_coordinator.load_with_confirmation, + ), on_load_library=self._load_library, - on_load_file=self._on_converted_reconstruction_loaded, + on_load_file=gated( + self._reconstruction_coordinator.after_edits, + self._reconstruction_coordinator.load_converted, + ), on_load_directory=self._navigate_to_reconstructions, on_canceled=self._refresh_browsers, on_refresh_trees=self._refresh_browsers, @@ -562,12 +580,16 @@ def __init__( language_manager=self.language_manager, dialogs=self.dialogs, status_bar=self.status_bar, - on_edit_voice_requested=self._edit_project_voice, + on_edit_voice_requested=gated( + self._reconstruction_coordinator.after_edits, + self._reconstruction_coordinator.open_project_voice, + ), on_favorite_changed=self._repaint_reconstruction_favorites, - on_sample_reconstruction_replaced=self._rebind_replaced_sample, + on_sample_reconstruction_replaced=self._reconstruction_coordinator.replace_sample, on_tab_switch=self._set_current_tab, on_nes_frequency_changed=self._retune_samples_for_rate, on_channels_changed=self._update_menu, + after_edits=self._reconstruction_coordinator.after_edits, ) self._edit_router = EditRouter(surfaces=self._sequencer_tab.edit_surfaces) @@ -706,15 +728,22 @@ def _setup_gui(self) -> None: self._update_menu() def _create_shortcut_bindings(self) -> ShortcutBindings: + """The call behind every action the menus and the keys reach. + + A gesture that reads or puts away a whole document, the project or the open reconstruction, + waits for the edits of the open reconstruction made before it, so it acts on what the + reader has drawn. + """ + after_edits = self._reconstruction_coordinator.after_edits return ShortcutBindings( - new_project=self._project_coordinator.new_project_with_confirmation, - open_project=self._project_coordinator.open_with_confirmation, - save_project=self._project_coordinator.save, - save_project_as=self._project_coordinator.save_as_dialog, + new_project=gated(after_edits, self._project_coordinator.new_project_with_confirmation), + open_project=gated(after_edits, self._project_coordinator.open_with_confirmation), + save_project=gated(after_edits, self._project_coordinator.save), + save_project_as=gated(after_edits, self._project_coordinator.save_as_dialog), project_properties=self._open_project_properties, - export_project=self._project_coordinator.export_project_dialog, - render_song=self._render_coordinator.open, - close_project=self._project_coordinator.close_with_confirmation, + export_project=gated(after_edits, self._project_coordinator.export_project_dialog), + render_song=gated(after_edits, self._render_coordinator.open), + close_project=gated(after_edits, self._project_coordinator.close_with_confirmation), exit=self._on_close, undo=self._sequencer_tab.undo, redo=self._sequencer_tab.redo, @@ -722,13 +751,13 @@ def _create_shortcut_bindings(self) -> ShortcutBindings: reconstruct_directory=self._reconstruct_directory_dialog, load_generation_settings=self._config_coordinator.load_dialog, save_generation_settings=self._config_coordinator.save_dialog, - open_reconstruction=self._reconstruction_coordinator.load_with_confirmation, - save_reconstruction=self._reconstruction_coordinator.save, - save_reconstruction_as=self._reconstruction_coordinator.save_as_dialog, - close_reconstruction=self._reconstruction_coordinator.close_with_confirmation, - export_wav=self._export_reconstruction_wav_dialog, - export_instruments=self._export_reconstruction_instruments_dialog, - add_reconstruction_to_sequencer=self._add_current_reconstruction_to_sequencer, + open_reconstruction=gated(after_edits, self._reconstruction_coordinator.load_with_confirmation), + save_reconstruction=gated(after_edits, self._reconstruction_coordinator.save), + save_reconstruction_as=gated(after_edits, self._reconstruction_coordinator.save_as_dialog), + close_reconstruction=gated(after_edits, self._reconstruction_coordinator.close_with_confirmation), + export_wav=gated(after_edits, self._export_reconstruction_wav_dialog), + export_instruments=gated(after_edits, self._export_reconstruction_instruments_dialog), + add_reconstruction_to_sequencer=gated(after_edits, self._add_current_reconstruction_to_sequencer), new_instrument=self._add_instrument, add_sample_from_file=self._add_sample_from_file, import_instrument=self._import_instrument, @@ -906,6 +935,17 @@ def _build_menu_bar_viewmodel(self) -> MenuBarViewModel: auto_expand_favorite_directories=self.session_manager.auto_expand_favorite_directories, ) + def _on_project_replaced(self) -> None: + """Fans one project replacement out to the two tabs that show the project. + + The controller exposes a single ``on_project_replaced`` slot, fired by a new, opened or + closed project and by every undo, redo and history jump; the composition root owns it. The + sequencer realigns its views first, then the Reconstructions tab follows the voice it + shows into the project now in place. + """ + self._sequencer_tab.realign_with_project() + self._reconstruction_coordinator.follow_replaced_project() + def _on_history_changed(self) -> None: """Fans one history change out to every consumer. @@ -1095,9 +1135,6 @@ def _on_playback_error(self, exception: Exception) -> None: self.language_manager["global.dialog.message.audio_playback_error"], ) - def _on_converted_reconstruction_loaded(self, filepath: Path) -> None: - self._reconstruction_coordinator.load_with_confirmation(filepath) - def _refresh_browsers(self) -> None: """Reads the disk afresh in every browser, so a reconstruction just written stands in each. @@ -1130,56 +1167,6 @@ def _repaint_reconstruction_favorites(self, node: FileSystemNode) -> None: def _navigate_to_reconstructions(self) -> None: self._set_current_tab(Tab.RECONSTRUCTIONS) - def _edit_project_voice(self, voice_id: str) -> None: - """Opens the voice list's selection on the Reconstructions tab, in the terms of its kind. - - A sample opens as the reconstruction behind it, waveform and stems and all; an instrument stands - on no recording, so the tab shows its envelopes alone. Either kind brings that tab to the - front, so the voice a reader asked to edit is the one in view. - """ - match self.project_manager.current.voice(voice_id): - case Sample() as sample: - self._reconstructions_tab.release_instrument() - self.reconstruction_manager.load_reconstruction_object( - sample.reconstruction, - name=sample.name, - ) - case Instrument(): - self._reconstructions_tab.edit_instrument(voice_id) - self._navigate_to_reconstructions() - case _: - logger.warning(f"Cannot edit unknown project voice: {voice_id}") - - def _rebind_replaced_sample( - self, - voice_id: str, - reconstruction: Reconstruction, - ) -> None: - """Points the open Reconstructions-tab document at the reconstruction replacing the one it edits. - - The editor and its owning sample share one reconstruction object, so a sample whose audio is - substituted takes its editor along. This runs while the sample still holds the outgoing - reconstruction, which is what identifies the open document as belonging to it. - - Args: - voice_id: The sample receiving a new reconstruction. - reconstruction: The reconstruction the sample is about to hold. - """ - sample = self.project_manager.current.voice(voice_id) - if not isinstance(sample, Sample) or sample.reconstruction is not self.reconstruction_manager.reconstruction: - return - - self.reconstruction_manager.apply_edited(reconstruction) - self._reconstructions_tab.update_reconstruction() - - def _regenerate_instrument( - self, - channel_name: ChannelName, - feature_key: FeatureKey, - features: Features, - ) -> None: - self._reconstruction_coordinator.regenerate_instrument(channel_name, feature_key, features) - def _on_reconstruction_updated( self, edit: ReconstructionEdit, @@ -1197,7 +1184,7 @@ def _on_reconstruction_updated( return with self.history.transaction( - HistoryAction.EDIT_RECONSTRUCTION, + edit.history_action, detail=self._edit_detail(sample.id, edit), coalesce=edit.coalesce_key(sample.id), ): @@ -1215,7 +1202,7 @@ def _instrument_edit_detail( return self._sequencer_tab.instrument_edit_detail(voice_id, feature_key) def _edit_detail(self, voice_id: str, edit: ReconstructionEdit) -> HistoryDetail: - """The history line an edit reads as: the feature it moved, or the recording it took out.""" + """The history line an edit reads as: the feature it moved, the recording it took out, or the rate it set.""" match edit: case ChannelEdit(): return self._sequencer_tab.reconstruction_edit_detail( @@ -1228,33 +1215,32 @@ def _edit_detail(self, voice_id: str, edit: ReconstructionEdit) -> HistoryDetail voice_id, edit.stem_name, ) + case Retune(): + return self._sequencer_tab.nes_frequency_detail(edit.nes_frequency) def _retune_samples_for_rate(self, nes_frequency: int) -> None: """Refreshes the stored reconstructions of samples left out of sync by a rate change. Song playback already follows the new rate; this re-synthesizes only the persistent rendered waveforms the Reconstructions tab edits, and only for the samples still off the - target rate. The batch runs in the background so the rate change stays responsive. + target rate. The batch runs in the background so the rate change stays responsive. The + sample open on the Reconstructions tab takes the rate as a step of its own, after the edits + the reader made before it, so it is left out of the batch. """ + open_voice_id = self.reconstruction_manager.voice_id + if self._owning_project_sample() is not None: + self._reconstruction_coordinator.request_rewrite(RateChange(nes_frequency=nes_frequency)) + targets = [ (sample.id, sample.reconstruction) for sample in samples(self.project_manager.current.voices) - if sample.reconstruction.config.nes_frequency != nes_frequency + if sample.reconstruction.config.nes_frequency != nes_frequency and sample.id != open_voice_id ] if not targets: return - if not self.retune_service.start(targets, nes_frequency): - return - - self.status_bar.set(self.language_manager["global.status.message.retuning_samples"]) - if self._editing_retuned_sample(nes_frequency): - self._reconstructions_tab.set_reconstruction_dimmed(True) - - def _editing_retuned_sample(self, nes_frequency: int) -> bool: - """Whether the open Reconstructions-tab document is a project sample this batch will retune.""" - sample = self._owning_project_sample() - return sample is not None and sample.reconstruction.config.nes_frequency != nes_frequency + if self.retune_service.start(targets, nes_frequency): + self.status_bar.set(self.language_manager["global.status.message.retuning_samples"]) def _on_retune_result(self, result: RetuneResult) -> None: match result: @@ -1267,15 +1253,17 @@ def _on_retune_result(self, result: RetuneResult) -> None: if not self.retune_service.is_running(): self.status_bar.set("") - self._reconstructions_tab.set_reconstruction_dimmed(False) def _apply_retuned_sample(self, retuned: RetunedSample) -> None: """Swaps a retuned reconstruction into its sample, folding it into the rate-change undo entry. A batch superseded by a newer rate change is discarded by the rate guard, so a stale result neither overwrites the current reconstruction nor appends a stray history entry. The - rate-keyed coalesce target rewrites the single ``SET_NES_FREQUENCY`` entry, and a sample - open in the Reconstructions tab rebinds so its editor and the project sample stay one object. + rate-keyed coalesce target rewrites the single ``SET_NES_FREQUENCY`` entry. + + A sample the reader opened on the Reconstructions tab since the batch started takes the + rate as a step of the open document, after the edits made there. A sample edited since the + batch started is retuned again from what it now holds, so the edit stands. """ project = self.project_manager.current sample = project.voices.get(retuned.voice_id) @@ -1286,7 +1274,18 @@ def _apply_retuned_sample(self, retuned: RetunedSample) -> None: if nes_frequency != project.settings.nes_frequency: return - is_open = sample.reconstruction is self.reconstruction_manager.reconstruction + if self.reconstruction_manager.voice_id == sample.id: + self._reconstruction_coordinator.request_rewrite(RateChange(nes_frequency=nes_frequency)) + return + + reconstruction = ( + retuned.reconstruction + if sample.reconstruction is retuned.source + else sample.reconstruction.with_nes_frequency(nes_frequency) + ) + if reconstruction is sample.reconstruction: + return + with self.history.transaction( HistoryAction.SET_NES_FREQUENCY, detail=self._sequencer_tab.nes_frequency_detail(nes_frequency), @@ -1294,15 +1293,9 @@ def _apply_retuned_sample(self, retuned: RetunedSample) -> None: ): self.project_controller.replace_sample_reconstruction( retuned.voice_id, - retuned.reconstruction, + reconstruction, ) - if is_open: - self.reconstruction_manager.apply_edited( - retuned.reconstruction, - ) - self._reconstructions_tab.update_reconstruction(refit_waveform=True) - def _open_project_properties(self) -> None: """Opens the properties dialog seeded with the current project's info. @@ -1432,15 +1425,16 @@ def _apply_audio_settings( self.audio_device_manager.set_buffer_size(buffer_size) def _owning_project_sample(self) -> Optional[Sample]: - reconstruction = self.reconstruction_manager.reconstruction - if reconstruction is None: + """The project sample the open document is, found by the voice id the document remembers.""" + voice_id = self.reconstruction_manager.voice_id + if voice_id is None: return None - for sample in samples(self.project_manager.current.voices): - if sample.reconstruction is reconstruction: + match self.project_manager.current.voice(voice_id): + case Sample() as sample: return sample - - return None + case _: + return None def _editing_project_sample(self) -> bool: return self._owning_project_sample() is not None @@ -1502,26 +1496,8 @@ def _update_title(self) -> None: ) ) - def _sync_reconstruction_ownership(self) -> None: - """Reflects sequencer ownership in the open reconstruction view. - - When the reconstruction on screen becomes a project sample — added to the sequencer — its - source audio and file location are detached. The open document follows so both locations read - as not applicable, matching an owned sample. The guard lets this run only when a file-backed - reconstruction is added while it is the one on screen. - """ - reconstruction_data = self.reconstruction_manager.current_reconstruction - if reconstruction_data is None or reconstruction_data.filepath is None: - return - - if self._owning_project_sample() is None: - return - - self.reconstruction_manager.detach_current_reconstruction() - self._reconstructions_tab.display_reconstruction() - def _on_project_state_changed(self) -> None: - self._sync_reconstruction_ownership() + self._reconstruction_coordinator.follow_project() self._update_title() self._update_menu() @@ -1626,46 +1602,22 @@ def _mute_channel(self, generator: ChannelName) -> None: """Flips one channel of the sequencer's mix, the gesture the Channels submenu offers.""" self._sequencer_tab.toggle_channel(generator) - def _show_confirmation_dialog( - self, - message: str, - ok_label: str, - ) -> None: - self.dialogs.show_confirmation( - tag=TAG_GLOBAL_DIALOG_EXIT_CONFIRMATION, - title=self.language_manager["global.dialog.title.exit_confirmation"], - message=message, - on_confirm=self._exit_application, - ok_label=ok_label, - ) - def _on_close(self) -> None: - if self.project_manager.is_dirty: - self._project_coordinator.show_exit_save_confirmation(on_confirm=self._exit_application) - - elif self._reconstruction_coordinator.is_unsaved() and not self._editing_project_sample(): - self._reconstruction_coordinator.show_exit_save_confirmation(on_confirm=self._exit_application) + """Exits once each owner of something unfinished has asked about it, one after another. - elif self._is_converter_active(): - self._show_confirmation_dialog( - self.language_manager["global.dialog.message.exit_conversion_in_progress"], - ok_label=self.language_manager["global.dialog.label.exit"], - ) - - elif self._is_library_generating(): - self._show_confirmation_dialog( - self.language_manager["global.dialog.message.exit_library_generation_in_progress"], - ok_label=self.language_manager["global.dialog.label.exit"], - ) - - else: - self._exit_application() - - def _is_converter_active(self) -> bool: - return self._main_tab.is_converter_active() - - def _is_library_generating(self) -> bool: - return self._instructions_tab.is_library_generating() + The edits of the open reconstruction land first, so each question asks about what the + reader has drawn. + """ + pass_gates( + ( + self._reconstruction_coordinator.after_edits, + self._project_coordinator.guard_exit, + self._reconstruction_coordinator.guard_exit, + self._main_tab.guard_exit, + self._instructions_tab.guard_exit, + ), + self._exit_application, + ) def _is_project_open(self) -> bool: return self.project_controller.is_open diff --git a/src/sampletones_application/categories/elements/global_.py b/src/sampletones_application/categories/elements/global_.py index e41ede4a9..af2acabcc 100644 --- a/src/sampletones_application/categories/elements/global_.py +++ b/src/sampletones_application/categories/elements/global_.py @@ -117,8 +117,10 @@ class GlobalMessageElements(AbstractElement): EXIT_CONVERSION_IN_PROGRESS = "exit_conversion_in_progress" EXIT_LIBRARY_GENERATION_IN_PROGRESS = "exit_library_generation_in_progress" EXIT_UNSAVED_RECONSTRUCTION = "exit_unsaved_reconstruction" + EDIT_VOICE_UNSAVED_RECONSTRUCTION = "edit_voice_unsaved_reconstruction" CLOSE_UNSAVED_RECONSTRUCTION = "close_unsaved_reconstruction" LOAD_UNSAVED_RECONSTRUCTION = "load_unsaved_reconstruction" + LOAD_REPLACED_RECONSTRUCTION = "load_replaced_reconstruction" PROJECT_SAVED_SUCCESSFULLY = "project_saved_successfully" PROJECT_SAVE_FAILED = "project_save_failed" PROJECT_EXPORTED_SUCCESSFULLY = "project_exported_successfully" @@ -163,6 +165,7 @@ class GlobalDialogTitleElements(AbstractElement): EXIT_CONFIRMATION = "exit_confirmation" SAVE_RECONSTRUCTION = "save_reconstruction" RECONSTRUCTION_SAVED = "reconstruction_saved" + EDIT_VOICE_UNSAVED_RECONSTRUCTION = "edit_voice_unsaved_reconstruction" CLOSE_UNSAVED_RECONSTRUCTION = "close_unsaved_reconstruction" LOAD_UNSAVED_RECONSTRUCTION = "load_unsaved_reconstruction" SAVE_PROJECT = "save_project" diff --git a/src/sampletones_application/categories/skipped.py b/src/sampletones_application/categories/skipped.py index 233ee56c3..cdc9cef00 100644 --- a/src/sampletones_application/categories/skipped.py +++ b/src/sampletones_application/categories/skipped.py @@ -6,31 +6,34 @@ from sampletones_application.categories.context import channel_label from sampletones_application.categories.manager import LanguageManager from sampletones_core.constants.enums import ChannelName -from sampletones_core.exporters.skipped import SkippedRow +from sampletones_core.exporters.skipped import SkippedRow, SkipReason from sampletones_core.project.voices.voice import VoiceUnion from sampletones_core.structures import IdentifiedCollection from sampletones_core.utils.display import display_id, display_voice_label MAX_REPORTED_ROWS: Final[int] = 12 ROW_BULLET: Final[str] = " - " +PARAGRAPH_BREAK: Final[str] = "\n\n" @dataclass(frozen=True) class SkippedRowMessages: - """The words the rows an export left silent are reported in. + """The words the rows an export wrote other than the song plays them are reported in. A row naming a voice on a channel the voice has no instrument for plays nothing in the song, - so the tracker formats write a note cut there. The report names each row where the reader - finds it in the tracker, and closes on how many more the list leaves out. + so the tracker formats write a note cut there. A transpose row the format has no pitch change + for keeps the note where it was. The report gives each reason a paragraph of its own, names each + row where the reader finds it in the tracker, and closes a paragraph on how many more it leaves + out. Attributes: - heading: The line introducing the rows. + headings: The line introducing the rows, per reason. row: The template one row is printed with. more: The template naming the rows past the ones listed. channels: The name each channel is printed under. """ - heading: str + headings: Dict[SkipReason, str] row: str more: str channels: Dict[ChannelName, str] @@ -46,7 +49,10 @@ def build(cls, language_manager: LanguageManager) -> Self: Self: The bundle the export result handler reads. """ return cls( - heading=language_manager["global.dialog.message.export_skipped_rows"], + headings={ + SkipReason.NO_INSTRUMENT: language_manager["global.dialog.message.export_skipped_rows"], + SkipReason.UNREACHED_TRANSPOSE: language_manager["global.dialog.message.export_untransposed_rows"], + }, row=language_manager["global.dialog.template.export_skipped_row"], more=language_manager["global.dialog.template.export_skipped_rows_more"], channels={channel: channel_label(language_manager, channel) for channel in ChannelName.items()}, @@ -57,21 +63,35 @@ def notice( skipped_rows: Tuple[SkippedRow, ...], voices: IdentifiedCollection[VoiceUnion], ) -> Optional[str]: - """Phrases the rows an export wrote as a note cut. + """Phrases the rows an export wrote other than the song plays them, a paragraph per reason. Args: - skipped_rows: The rows the export left silent, in the order the song plays them. + skipped_rows: The rows the export reported, in the order the song plays them. voices: The project's voices, which the rows name their voice through. Returns: - Optional[str]: The lines the export dialog appends, and ``None`` where no row was - left silent. + Optional[str]: The paragraphs the export dialog appends, and ``None`` where every row + was written as the song plays it. """ - if not skipped_rows: + paragraphs = [ + self._paragraph(reason, rows, voices) + for reason in SkipReason + if (rows := tuple(skipped for skipped in skipped_rows if skipped.reason == reason)) + ] + if not paragraphs: return None + return PARAGRAPH_BREAK.join(paragraphs) + + def _paragraph( + self, + reason: SkipReason, + skipped_rows: Tuple[SkippedRow, ...], + voices: IdentifiedCollection[VoiceUnion], + ) -> str: + """One reason's rows under its heading, cut after the rows a paragraph lists.""" listed = skipped_rows[:MAX_REPORTED_ROWS] - lines = [self.heading, *(f"{ROW_BULLET}{self._row(skipped, voices)}" for skipped in listed)] + lines = [self.headings[reason], *(f"{ROW_BULLET}{self._row(skipped, voices)}" for skipped in listed)] remaining = len(skipped_rows) - len(listed) if remaining > 0: lines.append(self.more.format(count=remaining)) diff --git a/src/sampletones_application/coordinators/export/nsf.py b/src/sampletones_application/coordinators/export/nsf.py index 518f01b50..e339ddf03 100644 --- a/src/sampletones_application/coordinators/export/nsf.py +++ b/src/sampletones_application/coordinators/export/nsf.py @@ -7,7 +7,6 @@ from sampletones_application.utils.file_dialogs.api import save_file_dialog from sampletones_application.utils.file_dialogs.filter import FileFilter from sampletones_application.utils.file_dialogs.result import ignore_none_path -from sampletones_application.utils.gui.frame import FrameCallbackManager from sampletones_application.view_model.shared.nsf.view import NSFExportViewModel from sampletones_core.exports.request import SampleExport from sampletones_shared.paths.extensions import EXT_FILE_NSF @@ -96,16 +95,8 @@ def _set_destination(self, filepath: Path) -> None: self._logic.set_destination(filepath) def _export(self) -> None: - """Takes the dialog off screen and hands the export over once that frame has finished. - - DearPyGui carries one modal at a time, and the export's window opens on the run's first - word, so the run starts on the frame after the one the dialog left the screen in. The setup - holds the application through that frame, and the running service holds it from there. - """ + """Takes the dialog off screen and starts the export, which the running service holds from there.""" self._take_off_screen() - FrameCallbackManager.set_frame_callback(self._hand_over) - - def _hand_over(self) -> None: self._logic.start() self._release() diff --git a/src/sampletones_application/coordinators/project.py b/src/sampletones_application/coordinators/project.py index a4ad15ea7..c255b2576 100644 --- a/src/sampletones_application/coordinators/project.py +++ b/src/sampletones_application/coordinators/project.py @@ -1,4 +1,3 @@ -from functools import partial from pathlib import Path from typing import Dict, List, Mapping, Optional, Tuple @@ -35,7 +34,7 @@ from sampletones_application.utils.file_dialogs.filter import FileFilter from sampletones_application.utils.file_dialogs.result import ignore_none_path from sampletones_application.utils.gui.dialogs import DialogsRenderer -from sampletones_application.utils.gui.frame import FrameCallbackManager +from sampletones_application.utils.gui.dialogs.outcome import SaveOutcome from sampletones_core.exporters.skipped import SkippedRow from sampletones_core.exporters.truncation import EnvelopeTruncation from sampletones_core.exports.backend import ExportBackend @@ -150,37 +149,57 @@ def close_with_confirmation(self) -> None: tag=TAG_GLOBAL_DIALOG_PROJECT_UNSAVED, title=self._title(GlobalDialogTitleElements.CLOSE_UNSAVED_PROJECT), message=self._message(GlobalMessageElements.CLOSE_UNSAVED_PROJECT), - on_save=self.save, + on_save=self._write_project, on_confirm=self._close, ok_label=self._label(DialogElements.DISCARD), ) else: self._close() - def show_exit_save_confirmation(self, on_confirm: VoidCallback) -> None: + def guard_exit(self, proceed: VoidCallback) -> None: + """Lets the exit go on, asking first to save a project with unsaved changes. + + Save and Exit both go on, so what the exit asks about next is asked in turn, and Cancel + keeps the application open. + """ + if not self.is_unsaved: + proceed() + return + self._dialogs.show_save_confirmation( tag=TAG_GLOBAL_DIALOG_PROJECT_UNSAVED, title=self._title(GlobalDialogTitleElements.EXIT_CONFIRMATION), message=self._message(GlobalMessageElements.EXIT_UNSAVED_PROJECT), - on_save=self.save, - on_confirm=on_confirm, + on_save=self._write_project, + on_confirm=proceed, ok_label=self._label(DialogElements.EXIT), ) - def save(self) -> bool: - """Saves the project to its current file, prompting for one when it has none. + def save(self) -> SaveOutcome: + """Saves the project to its current file, prompting for one when it has none, and says so. + + Reports what the save came to, the way :meth:`_write_project` does. + """ + return self._announced(self._write_project()) + + def save_as_dialog(self) -> SaveOutcome: + """Prompts for a destination and saves the project there, saying so once it is written.""" + return self._announced(self._write_to_chosen_file()) + + def _write_project(self) -> SaveOutcome: + """Writes the project to its current file, prompting for one when it has none. - Reports whether the project was written, so a caller waiting on the save (the exit and - close prompts) proceeds only once it lands on disk and holds when the user cancels. + A save prompt waits on this, going on once the project lands on disk and asking again when + the reader closes the file dialog. The reader asked to go on, so the save goes on without a + word of its own, and whatever the prompt guards opens alone. """ filepath = self._session_manager.current_project if filepath is None: - return self.save_as_dialog() + return self._write_to_chosen_file() - return self._save(filepath) + return self._write(filepath) - def save_as_dialog(self) -> bool: - """Prompts for a destination and saves the project there, reporting whether it was written.""" + def _write_to_chosen_file(self) -> SaveOutcome: path = self._session_manager.get_project_path() filename = path.name if path.is_file() else DEFAULT_PROJECT_FILENAME directory = get_directory(path) @@ -193,6 +212,17 @@ def save_as_dialog(self) -> bool: return self._handle_save_as(filepath) + def _announced(self, outcome: SaveOutcome) -> SaveOutcome: + """Tells the reader a save they asked for by itself has landed.""" + if outcome is SaveOutcome.WRITTEN: + self._dialogs.show_info( + TAG_GLOBAL_DIALOG_PROJECT_SAVED, + self._message(GlobalMessageElements.PROJECT_SAVED_SUCCESSFULLY), + self._title(GlobalDialogTitleElements.PROJECT_SAVED), + ) + + return outcome + def _project_filters(self) -> Tuple[FileFilter, ...]: """The single type a project of this application's own is written as and read from.""" return ( @@ -255,10 +285,10 @@ def _handle_open(self, filepath: Path) -> None: self._session_manager.set_project_path(filepath.parent) self._load(filepath) - @ignore_none_path(default=False) - def _handle_save_as(self, filepath: Path) -> bool: + @ignore_none_path(default=SaveOutcome.CALLED_OFF) + def _handle_save_as(self, filepath: Path) -> SaveOutcome: self._session_manager.set_project_path(filepath.parent) - return self._save(filepath) + return self._write(filepath) @ignore_none_path def _handle_export_project(self, filepath: Path, export_format: ExportFormat) -> None: @@ -291,7 +321,7 @@ def _load(self, filepath: Path) -> None: self._session_manager.set_current_project(filepath) self._on_tab_switch(Tab.SEQUENCER) - def _save(self, filepath: Path) -> bool: + def _write(self, filepath: Path) -> SaveOutcome: try: self._project_controller.save(filepath) except (SerializationError, OSError) as exception: @@ -303,22 +333,13 @@ def _save(self, filepath: Path) -> bool: exception, self._message(GlobalMessageElements.PROJECT_SAVE_FAILED), ) - return False + return SaveOutcome.FAILED self._session_manager.set_current_project(filepath) - self._dialogs.show_info( - TAG_GLOBAL_DIALOG_PROJECT_SAVED, - self._message(GlobalMessageElements.PROJECT_SAVED_SUCCESSFULLY), - self._title(GlobalDialogTitleElements.PROJECT_SAVED), - ) - return True + return SaveOutcome.WRITTEN def _on_export_result(self, result: ExportResult) -> None: - """Reports a finished project export in the words of the format it was written in. - - A run long enough to watch held a window while it ran, and DearPyGui carries one modal at - a time, so the report waits for the frame that draws the screen without it. - """ + """Reports a finished project export in the words of the format it was written in.""" match result: case ExportSuccess( kind=ExportKind.PROJECT, @@ -326,25 +347,19 @@ def _on_export_result(self, result: ExportResult) -> None: skipped_rows=skipped_rows, truncation=truncation, ): - self._present( - partial( - self._dialogs.show_info, - TAG_GLOBAL_DIALOG_MODULE_EXPORTED, - self._exported_message(export_format, skipped_rows, truncation), - self._title(GlobalDialogTitleElements.PROJECT_EXPORTED), - ) + self._dialogs.show_info( + TAG_GLOBAL_DIALOG_MODULE_EXPORTED, + self._exported_message(export_format, skipped_rows, truncation), + self._title(GlobalDialogTitleElements.PROJECT_EXPORTED), ) case ExportError( kind=ExportKind.PROJECT, export_format=ExportFormat() as export_format, exception=exception, ): - self._present( - partial( - self._dialogs.show_error, - exception, - self._message(EXPORT_PROJECT_ELEMENTS[export_format].export_failed_message), - ) + self._dialogs.show_error( + exception, + self._message(EXPORT_PROJECT_ELEMENTS[export_format].export_failed_message), ) def _exported_message( @@ -368,10 +383,6 @@ def _exported_message( return "\n\n".join(paragraphs) - def _present(self, raise_dialog: VoidCallback) -> None: - """Raises ``raise_dialog`` once the frame the export window left the screen in has finished.""" - FrameCallbackManager.set_frame_callback(raise_dialog) - def _guard_open( self, *, @@ -389,7 +400,7 @@ def _guard_open( tag=TAG_GLOBAL_DIALOG_PROJECT_UNSAVED, title=self._title(title), message=self._message(message), - on_save=self.save, + on_save=self._write_project, on_confirm=on_confirm, ok_label=self._label(DialogElements.DISCARD), ) diff --git a/src/sampletones_application/coordinators/reconstruction.py b/src/sampletones_application/coordinators/reconstruction.py index 451268368..3dcffb235 100644 --- a/src/sampletones_application/coordinators/reconstruction.py +++ b/src/sampletones_application/coordinators/reconstruction.py @@ -7,21 +7,25 @@ from sampletones_application.coordinators.tabs.reconstruction import ( ReconstructionTabCoordinator, ) +from sampletones_application.logic.history.manager import HistoryManager +from sampletones_application.logic.project.controller import ProjectController from sampletones_application.logic.reconstruction.edit import ( ChannelEdit, ReconstructionEdit, + Retune, + StemRemoval, ) from sampletones_application.logic.reconstruction.manager import ReconstructionManager -from sampletones_application.services import ( - RegeneratedInstrument, - RegenerationResult, - RegenerationService, - ServiceCanceled, - ServiceError, - ServiceSuccess, +from sampletones_application.logic.reconstruction.rewrites.queue import ( + ReconstructionRewrites, +) +from sampletones_application.logic.reconstruction.rewrites.steps import ( + AfterEdits, + Rewrite, ) from sampletones_application.tags.general import ( TAG_GLOBAL_DIALOG_EXIT_CONFIRMATION, + TAG_GLOBAL_DIALOG_RECONSTRUCTION_REPLACED, TAG_GLOBAL_DIALOG_RECONSTRUCTION_SAVED, ) from sampletones_application.utils.file_dialogs.api import ( @@ -31,9 +35,11 @@ from sampletones_application.utils.file_dialogs.filter import FileFilter from sampletones_application.utils.file_dialogs.result import ignore_none_path from sampletones_application.utils.gui.dialogs import DialogsRenderer +from sampletones_application.utils.gui.dialogs.outcome import SaveOutcome from sampletones_core.audio import AudioDeviceManager -from sampletones_core.constants.enums import ChannelName, FeatureKey -from sampletones_core.exporters import Features +from sampletones_core.project.voices.instrument import Instrument +from sampletones_core.project.voices.sample import Sample +from sampletones_core.reconstructions import Reconstruction from sampletones_shared.exceptions import SampleToNESError from sampletones_shared.logger import logger from sampletones_shared.paths.extensions import EXT_FILE_RECONSTRUCTION @@ -49,8 +55,13 @@ class ReconstructionCoordinator: - It can be opened from multiple entry points. - It must be saved before replacement. - Its dirty/saved state drives the window title. - - Menu bar instrument regeneration flows through it so that all - reconstruction mutations remain centralized. + - Every edit of the open document flows through it, one step at a time in the + reader's order (see :class:`ReconstructionRewrites`), so all reconstruction + mutations remain centralized. + - Every change from outside the tab that replaces the open document passes + through it: opening a project voice, a replaced sample, and a project change + that restores, removes or lets go of the voice the tab shows. Each puts away + the edits meant for the document it replaces. The reconstructions tab is wired in after construction through ``set_reconstructions_tab``; ``_tab`` asserts it is present before first use. @@ -60,30 +71,35 @@ def __init__( self, reconstruction_manager: ReconstructionManager, session_manager: SessionManager, - regeneration_service: RegenerationService, + rewrites: ReconstructionRewrites, audio_device_manager: AudioDeviceManager, + project_controller: ProjectController, + history: HistoryManager, *, dialogs: DialogsRenderer, language_manager: LanguageManager, on_tab_switch: Callback, on_session_state_changed: VoidCallback, on_reconstruction_updated: Callable[[ReconstructionEdit], None], - is_reconstruction_embedded: Callable[[], bool], ) -> None: self._reconstruction_manager = reconstruction_manager self._session_manager = session_manager - self._regeneration_service = regeneration_service + self._rewrites = rewrites self._audio_device_manager = audio_device_manager + self._project_controller = project_controller + self._history = history self._reconstructions_tab: Optional[ReconstructionTabCoordinator] = None self._dialogs = dialogs self._language_manager = language_manager self._on_tab_switch = on_tab_switch self._on_session_state_changed_callback = on_session_state_changed self._on_reconstruction_updated_callback = on_reconstruction_updated - self._is_reconstruction_embedded = is_reconstruction_embedded self._reconstruction_manager.session.on_state_changed = self._on_state_changed - self._regeneration_service.subscribe(self._on_regeneration_result) + self._rewrites.on_edit = self.apply_edit + self._rewrites.on_dropped = self._redraw_open_document + self._rewrites.on_failed = self._report_rebuild_failure + self._rewrites.on_busy_changed = self._set_reconstruction_dimmed self._reconstruction_manager.set_callbacks( on_reconstruction_loaded=self.on_reconstruction_loaded, on_reconstruction_closed=self._on_closed, @@ -116,10 +132,10 @@ def _requires_save_confirmation(self) -> bool: """Reports pending edits that a save prompt can resolve. A prompt is warranted only for a standalone reconstruction with unsaved changes. A - project-embedded reconstruction has no file of its own, and its edits belong to the - project — closing or replacing it loses nothing, so it needs no prompt. + project sample has no file of its own, and its edits belong to the project — closing or + replacing it loses nothing, so it needs no prompt. """ - return self.is_unsaved() and not self._is_reconstruction_embedded() + return self.is_unsaved() and not self._reconstruction_manager.is_project_sample def check_loaded(self) -> bool: if not self.is_loaded(): @@ -129,10 +145,27 @@ def check_loaded(self) -> bool: return True - def save_as_dialog(self) -> None: + def save_as_dialog(self) -> SaveOutcome: + """Saves the open reconstruction to a file the reader picks, and says so once it is written.""" + outcome = self._save_to_chosen_file() + if outcome is SaveOutcome.WRITTEN: + self._dialogs.show_info( + TAG_GLOBAL_DIALOG_RECONSTRUCTION_SAVED, + self._language_manager["global.dialog.message.reconstruction_saved_successfully"], + self._language_manager["global.dialog.title.reconstruction_saved"], + ) + + return outcome + + def _save_to_chosen_file(self) -> SaveOutcome: + """Asks where the open reconstruction goes, writes it there and adopts that file as its own. + + A document that came from a file offers that file's place, and one without a file offers + the reconstructions folder under its own name. + """ reconstruction_data = self._reconstruction_manager.current_reconstruction if reconstruction_data is None: - return + return SaveOutcome.CALLED_OFF filepath = reconstruction_data.filepath if filepath is not None: @@ -142,14 +175,25 @@ def save_as_dialog(self) -> None: default_filename = get_filename(reconstruction_data.name, EXT_FILE_RECONSTRUCTION) default_path = str(self._session_manager.get_reconstruction_path()) - filepath = save_file_dialog( + chosen = save_file_dialog( title=self._language_manager["global.dialog.title.save_reconstruction"], initial_directory=default_path, default_filename=default_filename, filters=self._reconstruction_filters(), ) + if chosen is None: + return SaveOutcome.CALLED_OFF - self._handle_save_as(filepath) + try: + self._reconstruction_manager.save_reconstruction_as(chosen) + except (OSError, SampleToNESError) as exception: + self._report_save_failure(exception, chosen) + return SaveOutcome.FAILED + + self._session_manager.set_reconstruction_path(chosen.parent) + self._session_manager.set_current_reconstruction(chosen) + self._tab.display_reconstruction() + return SaveOutcome.WRITTEN def _reconstruction_filters(self) -> Tuple[FileFilter, ...]: """The single type a reconstruction is written as and read from.""" @@ -160,31 +204,6 @@ def _reconstruction_filters(self) -> Tuple[FileFilter, ...]: ), ) - @ignore_none_path - def _handle_save_as(self, filepath: Path) -> None: - try: - self._reconstruction_manager.save_reconstruction_as(filepath) - except (OSError, SampleToNESError) as exception: - logger.error_with_traceback( - exception, - f"Failed to save reconstruction to {filepath}", - ) - self._dialogs.show_error( - exception, - self._language_manager["global.dialog.message.reconstruction_save_failed"], - ) - - return - - self._session_manager.set_reconstruction_path(filepath.parent) - self._session_manager.set_current_reconstruction(filepath) - self._tab.display_reconstruction() - self._dialogs.show_info( - TAG_GLOBAL_DIALOG_RECONSTRUCTION_SAVED, - self._language_manager["global.dialog.message.reconstruction_saved_successfully"], - self._language_manager["global.dialog.title.reconstruction_saved"], - ) - def _load_dialog(self) -> None: filepath = open_file_dialog( title=self._language_manager["reconstructions.browser.title.load_reconstruction_dialog"], @@ -205,16 +224,32 @@ def load_reconstruction() -> None: else: self.load(filepath) - if self._requires_save_confirmation(): - self._show_save_confirmation( + self._save_first( + title=self._language_manager["global.dialog.title.load_unsaved_reconstruction"], + message=self._language_manager["global.dialog.message.load_unsaved_reconstruction"], + ok_label=self._language_manager["global.dialog.label.discard"], + proceed=load_reconstruction, + ) + + def load_converted(self, filepath: Path) -> None: + """Loads the reconstruction a conversion wrote, asking first about unsaved changes. + + A conversion can write over the very file the open document came from, and a save would + then write the old document over the new one. The question in that case is whether to + discard the changes and load, and Cancel keeps them for a save to another file. Every other + document is loaded the way one opened by hand is. + """ + if self._requires_save_confirmation() and self._reconstruction_manager.is_backed_by(filepath): + self._dialogs.show_confirmation( + tag=TAG_GLOBAL_DIALOG_RECONSTRUCTION_REPLACED, + message=self._language_manager["global.dialog.message.load_replaced_reconstruction"], title=self._language_manager["global.dialog.title.load_unsaved_reconstruction"], - message=self._language_manager["global.dialog.message.load_unsaved_reconstruction"], - on_save=self.save, - on_confirm=load_reconstruction, + on_confirm=lambda: self.load(filepath), ok_label=self._language_manager["global.dialog.label.discard"], ) - else: - load_reconstruction() + return + + self.load_with_confirmation(filepath) def load_reconstruction_safely(self, path: Path) -> None: """Loads the persisted reconstruction when the application starts. @@ -230,13 +265,16 @@ def load_reconstruction_safely(self, path: Path) -> None: logger.warning(f"Could not restore reconstruction from {logger.format_path(path)}: {exception}") self._session_manager.set_current_reconstruction(None) - def show_exit_save_confirmation(self, on_confirm: Callback) -> None: - self._show_save_confirmation( + def guard_exit(self, proceed: VoidCallback) -> None: + """Lets the exit go on, asking first to save a standalone reconstruction with unsaved changes. + + A project sample's changes belong to the project, which the exit asks about on its own. + """ + self._save_first( title=self._language_manager["global.dialog.title.exit_confirmation"], message=self._language_manager["global.dialog.message.exit_unsaved_reconstruction"], - on_save=self.save, - on_confirm=on_confirm, ok_label=self._language_manager["global.dialog.label.exit"], + proceed=proceed, ) @ignore_none_path @@ -244,54 +282,71 @@ def _handle_load(self, filepath: Path) -> None: self._session_manager.set_reconstruction_path(filepath.parent) self._tab.load_reconstruction(filepath) - def save(self, filepath: Optional[Path] = None) -> bool: - """Saves the open reconstruction, reporting whether it was written. + def save(self, filepath: Optional[Path] = None) -> SaveOutcome: + """Saves the open reconstruction, reporting what the save came to. - The exit and close prompts wait on this, so they proceed only once it lands on disk and - hold otherwise. + The save prompts wait on this: a document written to disk lets them go on, a save the + reader calls off asks again, and a write that fails shows its error. A standalone document + whose file was taken away asks where to go, the way Save As does, so its save prompt has a + way forward. """ - return self._reconstruction_manager.save_reconstruction(filepath) + manager = self._reconstruction_manager + if filepath is None and not manager.is_file_backed and not manager.is_project_sample: + return self._save_to_chosen_file() + + try: + written = self._reconstruction_manager.save_reconstruction(filepath) + except (OSError, SampleToNESError) as exception: + self._report_save_failure(exception, filepath or self._reconstruction_manager.filepath) + return SaveOutcome.FAILED + + return SaveOutcome.WRITTEN if written else SaveOutcome.CALLED_OFF + + def _report_save_failure(self, exception: Exception, filepath: Optional[Path]) -> None: + logger.error_with_traceback( + exception, + f"Failed to save reconstruction to {filepath}", + ) + self._dialogs.show_error( + exception, + self._language_manager["global.dialog.message.reconstruction_save_failed"], + ) def close_with_confirmation(self) -> None: - if self._requires_save_confirmation(): - self._show_save_confirmation( - title=self._language_manager["global.dialog.title.close_unsaved_reconstruction"], - message=self._language_manager["global.dialog.message.close_unsaved_reconstruction"], - on_confirm=self._close, - on_save=self.save, - ok_label=self._language_manager["global.dialog.label.close"], - ) - else: - self._close() + self._save_first( + title=self._language_manager["global.dialog.title.close_unsaved_reconstruction"], + message=self._language_manager["global.dialog.message.close_unsaved_reconstruction"], + ok_label=self._language_manager["global.dialog.label.close"], + proceed=self._close, + ) def _close(self) -> None: + self._drop_pending() self._reconstruction_manager.close_reconstruction() - def regenerate_instrument( - self, - channel_name: ChannelName, - feature_key: FeatureKey, - features: Features, - ) -> None: - reconstruction_data = self._reconstruction_manager.current_reconstruction - if reconstruction_data is None: - return + def request_rewrite(self, rewrite: Rewrite) -> None: + """Asks for a change of the open document, which it takes after the changes asked for before it.""" + self._rewrites.request(rewrite) - accepted = self._regeneration_service.start( - reconstruction_data.reconstruction, - channel_name, - feature_key, - features, - self._tab.heard_on(channel_name), - ) - if accepted: - self._set_reconstruction_dimmed(True) + def after_edits(self, gesture: VoidCallback) -> None: + """Runs a gesture that reads or puts away the whole document once the edits before it have landed. + + Undo, a save, a load or an export acts on the document the reader has drawn, so it waits + for the edits still on their way. With nothing on its way, the gesture runs at once. The + signature is a :data:`Gate`, so the wait can lead a chain of gates. + """ + self._rewrites.request(AfterEdits(gesture)) + + def _drop_pending(self) -> None: + """Puts away the edits meant for the open document, which an outside change is about to replace.""" + self._rewrites.drop() def on_reconstruction_loaded(self) -> None: reconstruction_data = self._reconstruction_manager.current_reconstruction if reconstruction_data is None: raise RuntimeError("No reconstruction is loaded after loading process") + self._drop_pending() self._audio_device_manager.stop() missing_path = first_missing(reconstruction_data.reconstruction.audio_filepath) if missing_path is not None: @@ -312,45 +367,161 @@ def _on_closed(self) -> None: def apply_edit(self, edit: ReconstructionEdit) -> None: """Applies an edited reconstruction across the open document and project. - Every edit of the open document arrives here, so one path answers a regenerated - instrument and a removed recording alike. The owning-sample hook runs first, while - the manager still holds the prior reconstruction, so it can locate the owned sample - by identity and record the edit against the project history as the ``edit`` - describes itself. The open document then rebinds to the new reconstruction, keeping - the editor and any owned sample sharing one object. + Every edit of the open document lands here as the rewrites take it, so one path answers a + regenerated instrument, a removed recording and a retune alike, in the reader's order. The + owning-sample hook runs first and records the edit against the project history as the + ``edit`` describes itself, so the history holds it by the time the tab shows it. The open + document then rebinds to the new reconstruction, the same object the sample now holds, and + the tab shows it. """ self._on_reconstruction_updated_callback(edit) self._reconstruction_manager.apply_edited(edit.reconstruction) - self._tab.update_reconstruction() + self._show_edit(edit) self._reconstruction_manager.mark_updated() - def _on_regeneration_result(self, result: RegenerationResult) -> None: - match result: - case ServiceSuccess(value=outcome): - self.apply_edit(self._channel_edit(outcome)) - case ServiceError(exception=exception): - logger.error_with_traceback(exception, "Regeneration failed") - self._dialogs.show_error(exception) - case ServiceCanceled(): - logger.info("Regeneration canceled") - - self._set_reconstruction_dimmed(self._regeneration_service.is_running()) - - @staticmethod - def _channel_edit(outcome: RegeneratedInstrument) -> ChannelEdit: - """Reads a regeneration result as the edit the project history records.""" - return ChannelEdit( - reconstruction=outcome.reconstruction, - channel_name=outcome.channel_name, - feature_key=outcome.feature_key, + def _show_edit(self, edit: ReconstructionEdit) -> None: + """Shows the edited document, redrawing the instruments panel where the edit came from elsewhere. + + A regenerated instrument carries the envelopes the panel's own edit wrote, so the panel + keeps drawing them and a field the reader is typing in keeps its text. A retune carries + every envelope over too, and its audio spans another length, so the waveform re-fits. A + removed recording releases frames the panel drew as sounding, so the panel draws every + channel as the document now holds it. + """ + match edit: + case ChannelEdit(): + self._tab.update_reconstruction() + case Retune(): + self._tab.update_reconstruction(refit_waveform=True) + case StemRemoval(): + self._tab.redraw_reconstruction(refit_waveform=False) + + def open_project_voice(self, voice_id: str) -> None: + """Opens a voice of the project on the Reconstructions tab, in the terms of its kind. + + Either kind takes the place of the open document, so a standalone document with unsaved + changes is offered a save first, the way loading a file offers it. + """ + self._save_first( + title=self._language_manager["global.dialog.title.edit_voice_unsaved_reconstruction"], + message=self._language_manager["global.dialog.message.edit_voice_unsaved_reconstruction"], + ok_label=self._language_manager["global.dialog.label.discard"], + proceed=lambda: self._open_project_voice(voice_id), ) + def _open_project_voice(self, voice_id: str) -> None: + """Puts a voice of the project in front of the tab. + + A sample opens as the reconstruction behind it, waveform and stems and all, and the + document remembers the voice it is. An instrument stands on no recording, so the tab + shows its envelopes alone. Either kind brings that tab to the front, so the voice a + reader asked to edit is the one in view. + """ + match self._project_controller.project.voice(voice_id): + case Sample() as sample: + self._tab.release_instrument() + self._reconstruction_manager.load_reconstruction_object( + sample.reconstruction, + name=sample.name, + voice_id=sample.id, + ) + case Instrument(): + self._drop_pending() + self._tab.edit_instrument(voice_id) + self._on_tab_switch(Tab.RECONSTRUCTIONS) + case _: + logger.warning(f"Cannot edit unknown project voice: {voice_id}") + + def replace_sample(self, voice_id: str, reconstruction: Reconstruction) -> None: + """Shows the reconstruction a sample now holds, where the tab has that sample open. + + A sample whose audio is substituted keeps its id, so the open document takes the incoming + reconstruction along. It brings envelopes of its own, so the tab redraws the instruments + panel from it. + + Args: + voice_id: The sample that received a new reconstruction. + reconstruction: The reconstruction the sample now holds. + """ + if self._reconstruction_manager.voice_id == voice_id: + self._rebind(reconstruction) + + def follow_project(self) -> None: + """Closes the voice the tab shows once the project stops holding it. + + Every change to the project reaches here, the tab's own edits included. An edit writes + the project before the open document shows it, so a voice the project still holds is + left as it stands and the panel keeps what the reader is drawing. + """ + self._follow(restored=False) + + def follow_replaced_project(self) -> None: + """Follows the voice the tab shows into a project put in place of the one it belonged to. + + An undo, a redo or a history jump installs a snapshot that keeps every voice's id, so a + voice it keeps is shown as restored and one it took out closes. Any other replacement + (a new, opened or closed project) lets the voice go, since a reopened file brings back + the same ids. + """ + if self._history.is_restoring: + self._follow(restored=True) + else: + self._let_go_of_project_voice() + + def _follow(self, *, restored: bool) -> None: + """Brings the voice in front of the tab in line with the project. + + A sample the project no longer holds closes. A restore hands a kept sample the + reconstruction it held then, which the tab rebinds to. The instrument the tab holds + follows by the same rule. + """ + voice_id = self._reconstruction_manager.voice_id + if voice_id is not None: + match self._project_controller.project.voice(voice_id): + case Sample() as sample: + if restored: + self._rebind(sample.reconstruction) + case _: + self._close() + + self._tab.follow_instrument(restored=restored) + + def _let_go_of_project_voice(self) -> None: + """Closes a project voice the tab shows, leaving a standalone document open.""" + if self._reconstruction_manager.is_project_sample: + self._close() + + self._tab.close_instrument() + + def _rebind(self, reconstruction: Reconstruction) -> None: + """Points the open document at the reconstruction its sample now holds, and redraws it. + + A reconstruction timed at another NES frequency spans another length, so the waveform + re-fits to it. The document the sample already holds needs no redraw. + """ + open_reconstruction = self._reconstruction_manager.reconstruction + if open_reconstruction is None or reconstruction is open_reconstruction: + return + + retimed = reconstruction.config.nes_frequency != open_reconstruction.config.nes_frequency + self._drop_pending() + self._reconstruction_manager.apply_edited(reconstruction) + self._tab.redraw_reconstruction(refit_waveform=retimed) + + def _redraw_open_document(self) -> None: + """Draws the open document as it stands, where the panel drew a change that will never land.""" + if self.is_loaded(): + self._tab.redraw_reconstruction(refit_waveform=False) + + def _report_rebuild_failure(self, exception: Exception) -> None: + logger.error_with_traceback(exception, "Regeneration failed") + self._dialogs.show_error(exception) + def _set_reconstruction_dimmed(self, dimmed: bool) -> None: - """Fades the reconstruction waveform while the regeneration worker is busy. + """Fades the reconstruction waveform while the open document is being rewritten. - The dim tracks the service's own ``is_running`` span, so a continuous edit stream keeps - the waveform faded until the worker settles. Each finished result re-reads the live span: - while more work is queued it stays faded, and it restores once the worker is idle. + The dim follows the rewrites' busy span, so a continuous edit stream keeps the waveform + faded until the last step lands, and it restores once nothing runs or waits. """ if self._reconstructions_tab is None: return @@ -360,19 +531,31 @@ def _set_reconstruction_dimmed(self, dimmed: bool) -> None: def _on_state_changed(self) -> None: self._on_session_state_changed_callback() - def _show_save_confirmation( + def _save_first( self, + *, title: str, message: str, - on_save: Callable[[], bool], - on_confirm: Callback, ok_label: str, + proceed: VoidCallback, ) -> None: + """Goes on with ``proceed``, offering first to save a standalone document with unsaved changes. + + Args: + title: The prompt's title. + message: What the prompt says would be lost. + ok_label: The label of the answer that goes on without saving. + proceed: What runs once the document is saved, or once the reader lets the changes go. + """ + if not self._requires_save_confirmation(): + proceed() + return + self._dialogs.show_save_confirmation( tag=TAG_GLOBAL_DIALOG_EXIT_CONFIRMATION, title=title, message=message, - on_save=on_save, - on_confirm=on_confirm, + on_save=self.save, + on_confirm=proceed, ok_label=ok_label, ) diff --git a/src/sampletones_application/coordinators/render.py b/src/sampletones_application/coordinators/render.py index 5b024108f..11f53f31f 100644 --- a/src/sampletones_application/coordinators/render.py +++ b/src/sampletones_application/coordinators/render.py @@ -1,4 +1,3 @@ -from functools import partial from pathlib import Path from typing import Dict, Optional, Tuple @@ -9,7 +8,6 @@ from sampletones_application.utils.file_dialogs.filter import FileFilter from sampletones_application.utils.file_dialogs.result import ignore_none_path from sampletones_application.utils.gui.dialogs import DialogsRenderer -from sampletones_application.utils.gui.frame import FrameCallbackManager from sampletones_application.view_model.shared.render import SongRenderViewModel from sampletones_core.audio.writers import AudioFormat, capability_of from sampletones_shared.types.callback import VoidCallback @@ -124,19 +122,16 @@ def _destination_filters(self, audio_format: AudioFormat) -> Tuple[FileFilter, . def _on_success(self, destination: Path) -> None: """Reports the file a finished render wrote, as a path that opens in the file manager.""" self._close() - self._present( - partial( - self._dialogs.show_message_with_path, - self._title_rendered, - self._msg_rendered, - destination, - ) + self._dialogs.show_message_with_path( + self._title_rendered, + self._msg_rendered, + destination, ) def _on_error(self, exception: Exception) -> None: """Reports what a render failed on, leaving the destination as it was.""" self._close() - self._present(partial(self._dialogs.show_error, exception, self._msg_failed)) + self._dialogs.show_error(exception, self._msg_failed) def _on_canceled(self) -> None: """Closes the dialog of a render that was stopped, which leaves no file to report.""" @@ -150,14 +145,6 @@ def _close(self) -> None: self._logic.close() self._on_activity_changed() - def _present(self, raise_dialog: VoidCallback) -> None: - """Raises ``raise_dialog`` once the frame the window left the screen in has finished. - - The render window is modal and DearPyGui carries one modal at a time, so a report waits - for the frame that draws the screen without it and opens onto a clear screen. - """ - FrameCallbackManager.set_frame_callback(raise_dialog) - def _require_view_model(self) -> SongRenderViewModel: """The render the dialog opens on. diff --git a/src/sampletones_application/coordinators/tabs/instructions.py b/src/sampletones_application/coordinators/tabs/instructions.py index 345dfc5d5..27028f0f1 100644 --- a/src/sampletones_application/coordinators/tabs/instructions.py +++ b/src/sampletones_application/coordinators/tabs/instructions.py @@ -25,6 +25,7 @@ SUF_PANEL_CENTER, SUF_PANEL_LEFT, SUF_PANEL_RIGHT, + TAG_GLOBAL_DIALOG_EXIT_CONFIRMATION, TAG_GLOBAL_TAB_INSTRUCTIONS, TAG_GLOBAL_TABS, TAG_GLOBAL_THEME_PANEL_GROUND, @@ -500,6 +501,20 @@ def save_browser_shape(self) -> None: def is_library_generating(self) -> bool: return self._library_logic.is_library_generating() + def guard_exit(self, proceed: VoidCallback) -> None: + """Lets the exit go on, asking first while a library is being built, which exiting stops.""" + if not self.is_library_generating(): + proceed() + return + + self._dialogs.show_confirmation( + TAG_GLOBAL_DIALOG_EXIT_CONFIRMATION, + self._language_manager["global.dialog.message.exit_library_generation_in_progress"], + self._language_manager["global.dialog.title.exit_confirmation"], + proceed, + ok_label=self._language_manager["global.dialog.label.exit"], + ) + def refresh_generate_button(self) -> None: self._library_panel.refresh_action_buttons() diff --git a/src/sampletones_application/coordinators/tabs/main.py b/src/sampletones_application/coordinators/tabs/main.py index 41d14e37b..8aa150f59 100644 --- a/src/sampletones_application/coordinators/tabs/main.py +++ b/src/sampletones_application/coordinators/tabs/main.py @@ -23,6 +23,7 @@ from sampletones_application.tags.general import ( SUF_PANEL_CENTER, SUF_PANEL_LEFT, + TAG_GLOBAL_DIALOG_EXIT_CONFIRMATION, TAG_GLOBAL_TAB_MAIN, TAG_GLOBAL_TABS, TAG_GLOBAL_THEME_PANEL_GROUND, @@ -753,6 +754,20 @@ def _sync_config_row_height(self) -> None: def is_converter_active(self) -> bool: return self._converter_logic.is_active + def guard_exit(self, proceed: VoidCallback) -> None: + """Lets the exit go on, asking first while a conversion runs, which exiting stops.""" + if not self.is_converter_active(): + proceed() + return + + self._dialogs.show_confirmation( + TAG_GLOBAL_DIALOG_EXIT_CONFIRMATION, + self._language_manager["global.dialog.message.exit_conversion_in_progress"], + self._language_manager["global.dialog.title.exit_confirmation"], + proceed, + ok_label=self._language_manager["global.dialog.label.exit"], + ) + def is_converter_panel_visible(self) -> bool: return self._converter_panel.is_visible() diff --git a/src/sampletones_application/coordinators/tabs/reconstruction.py b/src/sampletones_application/coordinators/tabs/reconstruction.py index 499ab9369..96744f9c2 100644 --- a/src/sampletones_application/coordinators/tabs/reconstruction.py +++ b/src/sampletones_application/coordinators/tabs/reconstruction.py @@ -1,6 +1,6 @@ from functools import partial from pathlib import Path -from typing import Callable, Dict, FrozenSet, Mapping, Optional, Sequence, Tuple +from typing import Callable, Dict, Mapping, Optional, Sequence, Tuple import dearpygui.dearpygui as dpg @@ -25,19 +25,23 @@ ) from sampletones_application.logic.reconstruction.browser.logic import BrowserLogic from sampletones_application.logic.reconstruction.browser.manager import BrowserManager -from sampletones_application.logic.reconstruction.edit import StemRemoval from sampletones_application.logic.reconstruction.editor import ( InstrumentEditDetail, InstrumentEditor, ) from sampletones_application.logic.reconstruction.instruments import ( - OnReconstructionInstrumentUpdatedCallback, + PendingChangesProtocol, ReconstructionInstrumentsLogic, ) from sampletones_application.logic.reconstruction.manager import ReconstructionManager from sampletones_application.logic.reconstruction.reconstruction import ( ReconstructionPanelLogic, ) +from sampletones_application.logic.reconstruction.rewrites.steps import ( + RateChange, + Rewrite, + StemRemovalRequest, +) from sampletones_application.logic.shared.file_playback import FilePlayback from sampletones_application.logic.shared.player import PlayerLogic from sampletones_application.logic.shared.tree import TreeLogic @@ -87,6 +91,7 @@ from sampletones_application.ui.panels.reconstruction.stems import ( GUIReconstructionStemsPanel, ) +from sampletones_application.utils.callbacks.gates import Gate, gated from sampletones_application.utils.file_dialogs.api import save_file_dialog from sampletones_application.utils.file_dialogs.filter import FileFilter from sampletones_application.utils.file_dialogs.result import ignore_none_path @@ -105,7 +110,6 @@ from sampletones_core.exports.backend import ExportBackend from sampletones_core.exports.format import ExportFormat from sampletones_core.exports.scope import ExportScope -from sampletones_core.reconstructions.reconstruction.stems.removal import without_stem from sampletones_core.structures.tree import FileSystemNode from sampletones_shared.exceptions import ( DeserializationError, @@ -139,8 +143,9 @@ def __init__( on_load_reconstruction_with_confirmation: Callable[[Optional[Path]], None], on_change_audio_state: VoidCallback, on_favorite_changed: Callable[[FileSystemNode], None], - on_reconstruction_instrument_updated: OnReconstructionInstrumentUpdatedCallback, - on_reconstruction_stem_removed: Callable[[StemRemoval], None], + on_rewrite_requested: Callable[[Rewrite], None], + pending_changes: PendingChangesProtocol, + after_edits: Gate, original_audio_locator: OriginalAudioLocator, instrument_exports: InstrumentExportCoordinator, history: HistoryManager, @@ -167,7 +172,7 @@ def __init__( self._instrument_exports = instrument_exports self._dialogs = dialogs self._original_audio_locator = original_audio_locator - self._on_reconstruction_stem_removed = on_reconstruction_stem_removed + self._on_rewrite_requested = on_rewrite_requested self._geometry = layout.geometry self._side_panel_count: int @@ -273,7 +278,7 @@ def __init__( self._reconstruction_instruments_panel.set_collapse_handler(self._on_instruments_collapse_changed) self._reconstruction_instruments_logic: ReconstructionInstrumentsLogic = ReconstructionInstrumentsLogic( self._instrument_editor, - scheduling=layout.scheduling, + pending_changes, ) self._instrument_audition_logic: InstrumentAuditionLogic = InstrumentAuditionLogic( self._instrument_editor, @@ -288,7 +293,7 @@ def __init__( self._browser_panel.on_directory_remove_requested = self._request_remove_directory self._reconstruction_audio_panel.on_audio_source_changed = self._reconstruction_panel_logic.set_audio_source - self._reconstruction_audio_panel.on_nes_frequency_changed = self._reconstruction_panel_logic.set_nes_frequency + self._reconstruction_audio_panel.on_nes_frequency_changed = self._request_rate_change self._reconstruction_plot_panel.on_channels_changed = self._reconstruction_panel_logic.set_selected_channels self._reconstruction_stems_panel.on_stem_channels_changed = self._reconstruction_panel_logic.set_stem_channels self._reconstruction_stems_panel.on_stem_solo_requested = self._reconstruction_panel_logic.solo_stem @@ -321,11 +326,9 @@ def __init__( self._reconstruction_instruments_logic.on_feature_data_changed = ( self._reconstruction_instruments_panel.update_feature_data ) - self._reconstruction_instruments_logic.on_reconstruction_instrument_updated = ( - on_reconstruction_instrument_updated - ) + self._reconstruction_instruments_logic.on_channel_changed = on_rewrite_requested - self._reconstruction_instruments_panel.on_instrument_export = self._export_instrument + self._reconstruction_instruments_panel.on_instrument_export = gated(after_edits, self._export_instrument) self._reconstruction_instruments_panel.on_reconstruction_instrument_hovered = ( self._reconstruction_plot_panel.set_overlay ) @@ -345,21 +348,17 @@ def __init__( self._reconstruction_instruments_logic.on_display_refreshed = self._instrument_audition_logic.refresh def _on_export_result(self, result: ExportResult) -> None: - """Reports a finished export in the words of the artifact it produced. - - A run long enough to watch held a window while it ran, and DearPyGui carries one modal at - a time, so the report waits for the frame that draws the screen without it. - """ + """Reports a finished export in the words of the artifact it produced.""" messages = self._export_messages match result: case ExportSuccess(kind=ExportKind.WAV, filepath=fp): - self._present_path(messages.wav_title, messages.wav_success, fp) + self._dialogs.show_message_with_path(messages.wav_title, messages.wav_success, fp) case ExportSuccess( kind=ExportKind.INSTRUMENT, filepath=fp, truncation=truncation, ): - self._present_path( + self._dialogs.show_message_with_path( messages.status_title, self._export_message( messages.instrument_success, @@ -373,7 +372,7 @@ def _on_export_result(self, result: ExportResult) -> None: filepath=fp, truncation=truncation, ): - self._present_path( + self._dialogs.show_message_with_path( messages.status_title, self._export_message( messages.instruments_success, @@ -383,19 +382,11 @@ def _on_export_result(self, result: ExportResult) -> None: fp, ) case ExportError(kind=ExportKind.WAV, exception=exception): - self._present_error(exception, messages.wav_failed) + self._dialogs.show_error(exception, messages.wav_failed) case ExportError(kind=ExportKind.INSTRUMENT, exception=exception): - self._present_error(exception, messages.instrument_failed) + self._dialogs.show_error(exception, messages.instrument_failed) case ExportError(kind=ExportKind.SAMPLE, exception=exception): - self._present_error(exception, messages.instruments_failed) - - def _present_path(self, title: str, message: str, filepath: Path) -> None: - """Reports a written file once the frame the export window left the screen in has finished.""" - FrameCallbackManager.set_frame_callback(partial(self._dialogs.show_message_with_path, title, message, filepath)) - - def _present_error(self, exception: Exception, message: str) -> None: - """Reports a failure once the frame the export window left the screen in has finished.""" - FrameCallbackManager.set_frame_callback(partial(self._dialogs.show_error, exception, message)) + self._dialogs.show_error(exception, messages.instruments_failed) def _export_message( self, @@ -677,6 +668,34 @@ def release_instrument(self) -> None: """Lets go of the instrument the tab held, which is what opening a reconstruction does.""" self._instrument_editor.release_instrument() + def follow_instrument(self, *, restored: bool) -> None: + """Brings the instrument the tab holds in line with the project it belongs to. + + An instrument the project no longer holds leaves the tab empty. A restore hands a kept + instrument the envelopes it had then, so the panel draws them. Any other change to the + project leaves the panel as drawn, so a keystroke in the tracker keeps what the reader + is writing here. + """ + if not self._instrument_editor.holds_instrument: + return + + if self._instrument_editor.instrument is None: + self.close_instrument() + elif restored: + self._reconstruction_instruments_logic.update_display() + + def close_instrument(self) -> None: + """Lets go of the instrument the tab holds and empties the tab, as a closed reconstruction leaves it. + + The waveform card draws the instrument's own audio while it is open, so it empties together + with the instruments panel. + """ + if not self._instrument_editor.holds_instrument: + return + + self._instrument_editor.release_instrument() + self.close_reconstruction() + def close_reconstruction(self) -> None: self._reconstruction_panel_logic.close_reconstruction() self._reconstruction_instruments_logic.update_display() @@ -701,26 +720,14 @@ def _request_remove_stem(self, stem_id: int) -> None: tag=TAG_RECONSTRUCTIONS_RECONSTRUCTION_DIALOG_REMOVE_STEM_CONFIRMATION, title=self._language_manager["reconstructions.reconstruction.title.remove_stem_dialog"], message=self._language_manager["reconstructions.reconstruction.message.remove_stem_message"], - on_confirm=lambda: self._remove_stem(stem_id, row.name), + on_confirm=lambda: self._on_rewrite_requested(StemRemovalRequest(stem_id=stem_id, stem_name=row.name)), ok_label=self._lbl_remove, path=row.path, ) - def _remove_stem(self, stem_id: int, name: str) -> None: - """Takes the recording out of the open document and hands the edit on to be recorded.""" - reconstruction_data = self._reconstruction_manager.current_reconstruction - if reconstruction_data is None: - return - - self._on_reconstruction_stem_removed( - StemRemoval( - reconstruction=without_stem( - reconstruction_data.reconstruction, - stem_id, - ), - stem_name=name, - ) - ) + def _request_rate_change(self, nes_frequency: int) -> None: + """Asks for the open document to be re-timed to the rate the reader typed.""" + self._on_rewrite_requested(RateChange(nes_frequency=nes_frequency)) def _request_remove_directory(self, directory: Path) -> None: self._dialogs.show_confirmation( @@ -733,10 +740,8 @@ def _request_remove_directory(self, directory: Path) -> None: ) def _remove_reconstruction(self, filepath: Path) -> None: - if self._reconstruction_manager.filepath == filepath: - self._reconstruction_manager.detach_current_reconstruction() - self._reconstruction_manager.mark_updated() - + """Deletes a reconstruction file, keeping the open document once the file it stood on is gone.""" + stood_on_it = self._reconstruction_manager.is_backed_by(filepath) try: self._browser_logic.remove_path(filepath) except OSError as exception: @@ -744,14 +749,15 @@ def _remove_reconstruction(self, filepath: Path) -> None: self._dialogs.show_error(exception, self._msg_load_error) return + if stood_on_it: + self._let_go_of_removed_file() + self._browser_panel.refresh() def _remove_directory(self, directory: Path) -> None: + """Deletes a folder, keeping the open document once the file it stood on inside it is gone.""" current_filepath = self._reconstruction_manager.filepath - if current_filepath is not None and current_filepath.is_relative_to(directory): - self._reconstruction_manager.detach_current_reconstruction() - self._reconstruction_manager.mark_updated() - + stood_in_it = current_filepath is not None and current_filepath.is_relative_to(directory) try: self._browser_logic.remove_path(directory) except OSError as exception: @@ -762,16 +768,42 @@ def _remove_directory(self, directory: Path) -> None: self._dialogs.show_error(exception, self._msg_load_error) return + if stood_in_it: + self._let_go_of_removed_file() + self._browser_panel.refresh() - def heard_on(self, channel_name: ChannelName) -> FrozenSet[int]: - """The recordings the reader hears on one channel, which is what an edit there reaches.""" - return self._reconstruction_panel_logic.heard_on(channel_name) + def _let_go_of_removed_file(self) -> None: + """Holds the open document as unsaved changes with no file of its own, and shows it that way. + + The reader still has the document in front of them, so its changes stay open for a save to + another file, and the Source card stops naming the file that is gone. + """ + self._reconstruction_manager.detach_current_reconstruction() + self._reconstruction_manager.mark_updated() + self.update_reconstruction() def update_reconstruction(self, *, refit_waveform: bool = False) -> None: + """Re-answers every reading of an edited document whose envelopes the instruments panel already draws. + + A regenerated instrument carries the envelopes the panel's own edit wrote, and a retune + carries every envelope over, so the panel keeps what it draws and a field the reader is + typing in keeps its text. + """ self._reconstruction_panel_logic.update_reconstruction(refit_waveform=refit_waveform) self._reconstruction_instruments_logic.refresh_view() + def redraw_reconstruction(self, *, refit_waveform: bool) -> None: + """Re-answers every reading of a document rewritten outside the instruments panel, envelopes included. + + A removed recording releases the frames it held, and a replaced sample or an undo brings + envelopes of its own, so the panel draws the document as it now stands and the next edit + starts from it. A document timed at another NES frequency spans another length, so the + caller that knows this asks the waveform to re-fit. + """ + self._reconstruction_panel_logic.update_reconstruction(refit_waveform=refit_waveform) + self._reconstruction_instruments_logic.update_display() + def set_reconstruction_dimmed(self, dimmed: bool) -> None: self._reconstruction_plot_panel.set_reconstruction_dimmed(dimmed) diff --git a/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py b/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py index 3b64873bd..c85cc1031 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py +++ b/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py @@ -1,3 +1,4 @@ +from functools import partial from pathlib import Path from typing import Callable, Sequence, Tuple @@ -61,6 +62,7 @@ from sampletones_application.ui.panels.sequencer.voices.panel import ( GUISequencerVoicesPanel, ) +from sampletones_application.utils.callbacks.gates import Gate, gated from sampletones_application.utils.gui.clipboard.selection import select_text_clipboard from sampletones_application.utils.gui.dialogs import DialogsRenderer from sampletones_application.utils.gui.frame import FrameCallbackManager @@ -113,6 +115,7 @@ def __init__( on_tab_switch: Callable[[Tab], None], on_nes_frequency_changed: Callable[[int], None], on_channels_changed: VoidCallback, + after_edits: Gate, ) -> None: self._project_controller = project_controller self._session_manager = session_manager @@ -125,6 +128,7 @@ def __init__( self._on_tab_switch = on_tab_switch self._on_nes_frequency_changed = on_nes_frequency_changed self._on_channels_changed = on_channels_changed + self._after_edits = after_edits self._language_manager = language_manager self._dialogs = dialogs @@ -327,7 +331,7 @@ def _wire_collapse_handlers(self) -> None: panel.set_collapse_handler(self._layout.on_card_collapse_changed) def _wire_module_callbacks(self) -> None: - self._sequencer_module_panel.on_nes_frequency = self._request_nes_frequency_change + self._sequencer_module_panel.on_nes_frequency = gated(self._after_edits, self._request_nes_frequency_change) self._sequencer_module_panel.on_rows_per_pattern = self._recorder.undoable( HistoryAction.SET_ROWS_PER_PATTERN, self._sequencer_tracker_logic.set_rows_per_pattern, @@ -550,7 +554,7 @@ def _wire_voices_callbacks(self) -> None: self._sequencer_voices_panel.voice_footprint = self._sequencer_voices_logic.build_voice_footprint self._sequencer_voices_panel.on_voice_selected = self._on_voice_selected self._sequencer_voices_panel.on_voice_edit_requested = self._sequencer_voices_logic.request_edit - self._sequencer_voices_panel.on_remove_requested = self._voices.remove + self._sequencer_voices_panel.on_remove_requested = gated(self._after_edits, self._voices.remove) self._sequencer_voices_panel.on_play_requested = self._sequencer_voices_logic.play_voice self._sequencer_voices_panel.on_move_requested = self._recorder.undoable( HistoryAction.MOVE_VOICE, @@ -558,18 +562,27 @@ def _wire_voices_callbacks(self) -> None: detail=self._history_detail.move_voice, ) self._sequencer_voices_panel.on_rename_committed = self._voices.submit_rename - self._sequencer_voices_panel.on_duplicate_requested = self._recorder.undoable( - HistoryAction.DUPLICATE_VOICE, - self._sequencer_voices_logic.duplicate_voice, - detail=self._history_detail.duplicate_voice, + self._sequencer_voices_panel.on_duplicate_requested = gated( + self._after_edits, + self._recorder.undoable( + HistoryAction.DUPLICATE_VOICE, + self._sequencer_voices_logic.duplicate_voice, + detail=self._history_detail.duplicate_voice, + ), ) self._sequencer_voices_panel.on_new_instrument_requested = self.add_instrument self._sequencer_voices_panel.on_add_sample_requested = self.add_sample_from_file self._sequencer_voices_panel.on_import_instrument_requested = self.import_instrument self._sequencer_voices_panel.voice_instruments = self._instrument_exports.voice_instruments - self._sequencer_voices_panel.on_export_instrument_requested = self._instrument_exports.request_voice + self._sequencer_voices_panel.on_export_instrument_requested = gated( + self._after_edits, + self._instrument_exports.request_voice, + ) self._sequencer_voices_panel.instrument_channels = self._sequencer_voices_logic.instrument_channels - self._sequencer_voices_panel.on_instrument_from_channel_requested = self.add_instrument_from_channel + self._sequencer_voices_panel.on_instrument_from_channel_requested = gated( + self._after_edits, + self.add_instrument_from_channel, + ) def _wire_browser_callbacks(self) -> None: self._sequencer_browser_panel.set_collapse_handler(self._layout.on_browser_collapse_changed) @@ -595,7 +608,6 @@ def _wire_project_callbacks(self) -> None: self._project_controller.on_settings_changed = self._sequencer_tracker_logic.push_settings self._project_controller.on_song_changed = self._on_song_changed self._project_controller.on_voices_changed = self._sequencer_voices_logic.push_voices - self._project_controller.on_project_replaced = self._on_project_replaced def _wire_history(self) -> None: self._sequencer_history_panel.on_undo = self.undo @@ -615,13 +627,14 @@ def _on_settings_changed( self._sequencer_module_panel.update_settings(view_model) self._sequencer_tracker_panel.update_settings(view_model) - def _on_project_replaced(self) -> None: + def realign_with_project(self) -> None: """Realigns the tab with a replaced project, keeping the mute set across history navigation. - Undo, redo, and history jumps replace the project as well, and the history manager reports - itself restoring throughout, so the channels the user is listening through carry across - them. A new, opened, or closed document begins a fresh listening session instead, with - every channel audible. + The application's fan-out of the controller's single ``on_project_replaced`` signal calls + this. Undo, redo, and history jumps replace the project as well, and the history manager + reports itself restoring throughout, so the channels the user is listening through carry + across them. A new, opened, or closed document begins a fresh listening session instead, + with every channel audible. """ if not self._history.is_restoring: self._sequencer_channels_logic.reset() @@ -659,13 +672,14 @@ def import_instrument(self) -> None: self._voices.import_instrument() def undo(self) -> None: - self._history.undo() + """Steps the project back, once the edits of the open document the reader made before it have landed.""" + self._after_edits(self._history.undo) def redo(self) -> None: - self._history.redo() + self._after_edits(self._history.redo) def jump_to_history(self, index: int) -> None: - self._history.jump_to(index) + self._after_edits(partial(self._history.jump_to, index)) def refresh_history(self) -> None: """Re-renders the history panel from the manager's current stack. @@ -811,8 +825,12 @@ def import_reconstruction_object(self, reconstruction: Reconstruction, name: str self._reconstructions.import_object(reconstruction, name) def replace_reconstruction(self, filepath: Path) -> None: - """Substitutes the selected sample's reconstruction with a browser file's.""" - self._reconstructions.replace_from_file(filepath) + """Substitutes the selected sample's reconstruction with a browser file's. + + The sample may be the one open on the Reconstructions tab, so the substitution waits for + the edits made there before it. + """ + self._after_edits(partial(self._reconstructions.replace_from_file, filepath)) def _dispatch_edit_voice(self, voice_id: str) -> None: self._on_edit_voice_requested(voice_id) diff --git a/src/sampletones_application/coordinators/tabs/sequencer/reconstructions.py b/src/sampletones_application/coordinators/tabs/sequencer/reconstructions.py index 905d75825..14f1be0b1 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer/reconstructions.py +++ b/src/sampletones_application/coordinators/tabs/sequencer/reconstructions.py @@ -236,11 +236,11 @@ def _commit_replace( """Substitutes a sample's reconstruction as one undoable gesture, renaming it to the source. The detail is composed while the sample still holds the outgoing reconstruction, so it reads - the name being replaced alongside the incoming one. The replacement is announced in the same - window, ahead of the substitution, because an editor holding the sample open recognizes it by - the identity of the reconstruction it is about to give up. The frequency adoption, the rename, - and the substitution share a single history entry, so one undo restores the previous rate, - name, and audio together. + the name being replaced alongside the incoming one. The replacement is announced once the + sample holds the incoming reconstruction, in the form the project keeps, so an editor holding + the sample open by its id shows what the project now plays. The frequency adoption, the + rename, and the substitution share a single history entry, so one undo restores the previous + rate, name, and audio together. """ detail = self._history_detail.replace_sample(voice_id, name) with self._history.transaction( @@ -251,5 +251,5 @@ def _commit_replace( self._tracker_logic.set_nes_frequency(adopt_frequency) self._voices_logic.rename_voice(voice_id, name) - self._on_sample_reconstruction_replaced(voice_id, reconstruction) self._browser_logic.replace_reconstruction(voice_id, reconstruction) + self._on_sample_reconstruction_replaced(voice_id, reconstruction) diff --git a/src/sampletones_application/layout/behavior/scheduling/delays.py b/src/sampletones_application/layout/behavior/scheduling/delays.py index 7a25eb70e..fd54eb759 100644 --- a/src/sampletones_application/layout/behavior/scheduling/delays.py +++ b/src/sampletones_application/layout/behavior/scheduling/delays.py @@ -3,5 +3,4 @@ class SchedulingDelays(BaseModel, extra="forbid", frozen=True): schedule: int - reconstruction_update: int cancel: int diff --git a/src/sampletones_application/logic/history/manager.py b/src/sampletones_application/logic/history/manager.py index f253a943a..d291427e1 100644 --- a/src/sampletones_application/logic/history/manager.py +++ b/src/sampletones_application/logic/history/manager.py @@ -75,10 +75,11 @@ def is_restoring(self) -> bool: """Whether an undo, redo, or jump is reinstalling a snapshot right now. Every project transition reaches its handlers through the controller's single - ``on_project_replaced`` signal, so a handler that keeps transient session state — a - listening mute set, an acknowledged prompt — reads this to recognize history - navigation and carry that state across it, while a new, opened, or closed document - starts it fresh. + ``on_project_replaced`` signal, which the composition root fans out to each tab. A + handler that keeps session state across history navigation reads this to recognize + it: the sequencer carries the listening mute set across it, and the Reconstructions + tab keeps the voice it shows and redraws it as restored, while a new, opened, or + closed document starts both fresh. """ return self._restoring diff --git a/src/sampletones_application/logic/project/controller.py b/src/sampletones_application/logic/project/controller.py index 95c43ca35..488c0cfe8 100644 --- a/src/sampletones_application/logic/project/controller.py +++ b/src/sampletones_application/logic/project/controller.py @@ -122,8 +122,9 @@ def replace_project(self, project: Project, *, clean: bool) -> None: """Installs a project restored from history and rebuilds every dependent view. Undo and redo install the whole project at once here: ``on_project_replaced`` - fires to rebuild the tabs wholesale, mirroring how loading a project refreshes - them. The fine-grained ``on_mutation`` signal fires only for new user edits, so + fires so each tab realigns with the restored project, mirroring how loading a + project refreshes them, and a tab showing one voice keeps it by its id. The + fine-grained ``on_mutation`` signal fires only for new user edits, so reinstalling a recorded snapshot leaves it quiet. ``clean`` reports whether the restored state is the one last saved to disk, letting the session drop the unsaved-changes flag when undo returns exactly to the save point. diff --git a/src/sampletones_application/logic/reconstruction/data.py b/src/sampletones_application/logic/reconstruction/data.py index b25f5bd8a..64923027e 100644 --- a/src/sampletones_application/logic/reconstruction/data.py +++ b/src/sampletones_application/logic/reconstruction/data.py @@ -88,13 +88,19 @@ def _recordings_for(self, reconstruction: Reconstruction) -> Tuple[np.ndarray, . A recording belongs to the entry standing at its position, so an entry the edit keeps carries its audio to the position it now holds and an entry taken out releases it. - Recordings the load left out stay out. + Recordings the load left out stay out. A document naming an entry this one holds no + recording for, such as one a restore brings back, comes back with no recording at all, + the way a document whose audio fails to load does. """ if not self.stem_audios: return () positions = {entry.id: index for index, entry in enumerate(self.reconstruction.stems_data.config.entries)} - return tuple(self.stem_audios[positions[entry.id]] for entry in reconstruction.stems_data.config.entries) + incoming = reconstruction.stems_data.config.entries + if any(entry.id not in positions for entry in incoming): + return () + + return tuple(self.stem_audios[positions[entry.id]] for entry in incoming) @classmethod def _assemble( diff --git a/src/sampletones_application/logic/reconstruction/edit.py b/src/sampletones_application/logic/reconstruction/edit.py index 802762777..7f9643183 100644 --- a/src/sampletones_application/logic/reconstruction/edit.py +++ b/src/sampletones_application/logic/reconstruction/edit.py @@ -1,6 +1,7 @@ from dataclasses import dataclass from typing import Optional, TypeAlias, Union +from sampletones_application.logic.history.action import HistoryAction from sampletones_application.logic.history.transaction import CoalesceKey from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.reconstructions import Reconstruction @@ -18,6 +19,10 @@ class ChannelEdit: channel_name: ChannelName feature_key: FeatureKey + @property + def history_action(self) -> HistoryAction: + return HistoryAction.EDIT_RECONSTRUCTION + def coalesce_key(self, voice_id: str) -> Optional[CoalesceKey]: """Consecutive edits of one sample run together, so a graph movement records one entry.""" return (voice_id,) @@ -30,9 +35,29 @@ class StemRemoval: reconstruction: Reconstruction stem_name: str + @property + def history_action(self) -> HistoryAction: + return HistoryAction.EDIT_RECONSTRUCTION + def coalesce_key(self, _voice_id: str) -> Optional[CoalesceKey]: """Each removal stands on its own, so one undo puts one recording back.""" return None -ReconstructionEdit: TypeAlias = Union[ChannelEdit, StemRemoval] +@dataclass(frozen=True) +class Retune: + """The reconstruction re-timed to another NES frequency, every instruction carried over.""" + + reconstruction: Reconstruction + nes_frequency: int + + @property + def history_action(self) -> HistoryAction: + return HistoryAction.SET_NES_FREQUENCY + + def coalesce_key(self, _voice_id: str) -> Optional[CoalesceKey]: + """A retune joins the rate change it follows, so one undo restores the rate and the audio together.""" + return (self.nes_frequency,) + + +ReconstructionEdit: TypeAlias = Union[ChannelEdit, StemRemoval, Retune] diff --git a/src/sampletones_application/logic/reconstruction/editor.py b/src/sampletones_application/logic/reconstruction/editor.py index 432d66914..57e9e7515 100644 --- a/src/sampletones_application/logic/reconstruction/editor.py +++ b/src/sampletones_application/logic/reconstruction/editor.py @@ -46,6 +46,11 @@ def release_instrument(self) -> None: """Lets go of the instrument, which is what opening a reconstruction does.""" self._voice_id = None + @property + def holds_instrument(self) -> bool: + """Whether the tab was given an instrument, which it keeps until it lets go, even once the voice leaves.""" + return self._voice_id is not None + @property def instrument(self) -> Optional[Instrument]: """The instrument in front of the tab, or ``None`` where it holds a reconstruction or nothing.""" diff --git a/src/sampletones_application/logic/reconstruction/instruments.py b/src/sampletones_application/logic/reconstruction/instruments.py index 210c6435f..5b374eb84 100644 --- a/src/sampletones_application/logic/reconstruction/instruments.py +++ b/src/sampletones_application/logic/reconstruction/instruments.py @@ -1,14 +1,11 @@ -from typing import Callable, Dict, Optional, Set +from typing import Callable, Dict, Optional, Protocol, Set from sampletones_application.constants.instruments import INSTRUMENT_CHANNEL -from sampletones_application.layout.behavior.scheduling.scheduling import ( - SchedulingBehavior, -) from sampletones_application.logic.reconstruction.editing import ( InstrumentEdit, InstrumentEditingProtocol, ) -from sampletones_application.utils.callbacks.queue import CallbackQueue +from sampletones_application.logic.reconstruction.rewrites.steps import ChannelChange from sampletones_application.view_model.reconstruction.envelopes import ( ChannelEnvelopesViewModel, ) @@ -16,9 +13,6 @@ InstrumentViewModel, ReconstructionInstrumentsViewModel, ) -from sampletones_application.view_model.reconstruction.update import ( - ReconstructionUpdate, -) from sampletones_application.view_model.shared.footprint import VoiceFootprintViewModel from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.exporters import Features, playing_channels, stands_by @@ -27,37 +21,51 @@ from sampletones_shared.types.callback import VoidCallback from sampletones_shared.utils.callbacks import CallbackMixin -OnReconstructionInstrumentUpdatedCallback = Callable[ - [ChannelName, FeatureKey, Features], - None, -] + +class PendingChangesProtocol(Protocol): + """The changes a reader made that are still on their way to the open document. + + The panel shows what the document will hold, so every reading of a reconstruction's envelopes + passes through the changes waiting to land. + """ + + def drawn(self, envelopes: ChannelEnvelopesViewModel) -> ChannelEnvelopesViewModel: + """The document's envelopes with every change on its way written over them.""" class ReconstructionInstrumentsLogic(CallbackMixin): + """What the instruments panel shows of the voice in front of it, and where an edit to it goes. + + A reconstruction's channel moved on the panel travels out as a :class:`ChannelChange`, which + the document takes in its turn. Until it lands, the envelopes the panel draws and the figures + measuring them read through the changes on their way, so they answer for what the document + will hold. + """ + def __init__( self, editor: InstrumentEditingProtocol, - *, - scheduling: SchedulingBehavior, + pending_changes: PendingChangesProtocol, ) -> None: self._editor = editor - self._scheduling = scheduling + self._pending_changes = pending_changes - self._pending_reconstruction_update: Optional[ReconstructionUpdate] = None self._silenced: Set[ChannelName] = set() self.on_view_changed: Optional[Callable[[ReconstructionInstrumentsViewModel], None]] = None self.on_feature_data_changed: Optional[Callable[[Optional[ChannelEnvelopesViewModel]], None]] = None - self.on_reconstruction_instrument_updated: Optional[OnReconstructionInstrumentUpdatedCallback] = None + self.on_channel_changed: Optional[Callable[[ChannelChange], None]] = None self.on_display_refreshed: Optional[VoidCallback] = None def update_display(self) -> None: """Renders whatever the panel has in front of it, envelopes and figures together. - The cards beside the panel describe the same voice, so the render is reported once it has - been made and they settle on it: an edit to an instrument redraws its waveform here. + A document opened, a recording removed and a new choice of what is heard each change what + the panel shows, so each redraws it from the document. The cards beside the panel describe + the same voice, so the render is reported once it has been made and they settle on it: an + edit to an instrument redraws its waveform here. """ - self.call(self.on_view_changed, self._build_view_model(self._current_generators())) + self.call(self.on_view_changed, self._build_view_model(self._measured_channels())) self.call(self.on_feature_data_changed, self._displayed_features()) self.call(self.on_display_refreshed) @@ -86,15 +94,17 @@ def _instrument_channels( def refresh_view(self) -> None: """Reports which channels play and the sizes they occupy, redrawing only a channel an edit silenced. - A regeneration replaces what an instrument exports, so the byte figures and the standing-by - channels settle on it. The envelopes themselves are left to the edit that started the - regeneration, so a field the user is still typing in keeps what they wrote. A channel the - edit silenced stands by in the document once the regeneration lands, holding no frame, so - the panel draws it empty and the next edit starts from what the document holds. + This answers a document whose envelopes the panel already draws: a regeneration of the + panel's own edit, or a retune, which carries every envelope over. The byte figures and the + standing-by channels settle on what an instrument now exports, and the envelopes stay as + drawn, so a field the user is still typing in keeps what they wrote. A channel the edit + silenced stands by in the document once the regeneration lands, holding no frame, so the + panel draws it empty and the next edit starts from what the document holds. A document + rewritten anywhere else is drawn whole through :meth:`update_display`. """ self.call( self.on_view_changed, - self._build_view_model(self._current_generators()), + self._build_view_model(self._measured_channels()), ) self._redraw_silenced() @@ -123,13 +133,43 @@ def _redraw_silenced(self) -> None: ), ) - def _current_generators(self) -> Optional[Dict[ChannelName, Features]]: - """The channels of the reconstruction in front of the panel, where one is.""" - envelopes = self._reconstruction_envelopes() - return None if envelopes is None else envelopes.channels + def _measured_channels(self) -> Optional[Dict[ChannelName, Features]]: + """The channels of the reconstruction in front of the panel, as its figures measure them. + + A channel measures as the document will hold it once the changes on their way land. A + change silencing every frame of a channel leaves it standing by, so that channel measures + standing by from the moment the change is made, with no frame and no figure. + """ + document = self._document_envelopes() + if document is None: + return None + + return { + channel_name: ( + features if features == document[channel_name] else self._as_regenerated(channel_name, features) + ) + for channel_name, features in self._pending_changes.drawn(document).channels.items() + } + + @staticmethod + def _as_regenerated(channel_name: ChannelName, features: Features) -> Features: + """The envelopes a channel holds once the regeneration has rebuilt it from ``features``. + + A channel the envelopes silence rests through every frame, which the document stands by, + describing no frame. + """ + if stands_by(channel_name, features): + return features.leave_to_channel(features.envelopes) + + return features def _reconstruction_envelopes(self) -> Optional[ChannelEnvelopesViewModel]: - """The reconstruction in front of the panel, where it holds one and no instrument.""" + """The envelopes of the reconstruction in front of the panel, the changes on their way included.""" + document = self._document_envelopes() + return None if document is None else self._pending_changes.drawn(document) + + def _document_envelopes(self) -> Optional[ChannelEnvelopesViewModel]: + """The envelopes the reconstruction in front of the panel holds, where it holds one and no instrument.""" match self._editor.edited_instrument(): case ChannelEnvelopesViewModel() as envelopes: return envelopes @@ -209,17 +249,23 @@ def handle_pitch_value_changed( """Moves the pitch one channel of a reconstruction has its frames measured against. A conversion states the value it found, and moving it rebuilds the channel's frames around - the new origin, so the edit travels back out through the regeneration. + the new origin, so the edit travels back out as a change of that channel. The panel offers + the pitch on a reconstruction's channels alone, so a change reaching it while it shows an + instrument, or a voice that has gone, draws the panel as it now stands and goes nowhere. """ - features = self._get_features(channel_name).model_copy(update={"initial_pitch": value}) - self._note_silenced(channel_name, features) - self._schedule_reconstruction_update( - ReconstructionUpdate( - channel_name, - FeatureKey.INITIAL_PITCH, - features, - ) - ) + match self._editor.edited_instrument(): + case ChannelEnvelopesViewModel() as document: + self._change( + document, + ChannelChange( + channel_name=channel_name, + feature_key=FeatureKey.INITIAL_PITCH, + envelopes={}, + initial_pitch=value, + ), + ) + case _: + self.update_display() def handle_envelope_changed( self, @@ -230,101 +276,47 @@ def handle_envelope_changed( """Takes one dimension as an edit leaves it, values and loop point together. The panel states the whole dimension, so a bar redrawn on the plot and a sequence typed - into the text field arrive the same way and are written the same way. + into the text field arrive the same way and are written the same way. An instrument stands + on no audio, so an edit reaches it at once, while a reconstruction's dimension travels back + out as a change of its channel. An edit reaching a panel whose voice has gone draws the + panel as it now stands and goes nowhere. """ - if self._write_instrument_envelope(feature_key, envelope): - return - - features = self._get_features(channel_name).with_envelope(feature_key, envelope) - self._note_silenced(channel_name, features) - self._report_edited_size(channel_name, features) - self._schedule_reconstruction_update(ReconstructionUpdate(channel_name, feature_key, features)) + match self._editor.edited_instrument(): + case InstrumentEdit(): + self._editor.write_envelope(feature_key, envelope) + self.update_display() + case ChannelEnvelopesViewModel() as document: + self._change( + document, + ChannelChange( + channel_name=channel_name, + feature_key=feature_key, + envelopes={feature_key: envelope}, + initial_pitch=None, + ), + ) + case None: + self.update_display() + + def _change(self, document: ChannelEnvelopesViewModel, change: ChannelChange) -> None: + """Sends a change on to the document, and reports the figures it leaves at once. + + The channel is read as the change leaves it before the change goes, so whether it + silences the channel is known by the time the rebuild lands. The figures are reported + afterwards, measuring what the edit leaves ahead of its rebuild. + + Args: + document: The envelopes the open document holds. + change: What the reader moved. + """ + drawn = self._pending_changes.drawn(document) + self._note_silenced(change.channel_name, change.rebased(drawn[change.channel_name])) + self.call(self.on_channel_changed, change) + self.call(self.on_view_changed, self._build_view_model(self._measured_channels())) def _note_silenced(self, channel_name: ChannelName, features: Features) -> None: - """Remembers whether the latest edit of a channel silences it, which its regeneration then shows.""" + """Remembers whether the latest edit of a channel silences it, which its rebuild then shows.""" if stands_by(channel_name, features): self._silenced.add(channel_name) else: self._silenced.discard(channel_name) - - def _write_instrument_envelope( - self, - feature_key: FeatureKey, - envelope: Envelope[int], - ) -> bool: - """Writes one dimension of the instrument in front of the panel, reporting whether it did. - - An instrument stands on no audio, so an edit reaches it at once rather than through the - regeneration a reconstruction's envelopes go back through. - """ - if self.instrument_edit is None: - return False - - self._editor.write_envelope(feature_key, envelope) - self.update_display() - return True - - def _report_edited_size( - self, - channel_name: ChannelName, - features: Features, - ) -> None: - """Reports what the edited envelope costs as the edit arrives, ahead of its regeneration. - - Measuring the envelope the user just wrote keeps the figures answering what is on screen - while the reconstruction is still being rebuilt. The channel is measured as the - regeneration will leave it, so an edit silencing every frame reads as standing by at once, - with no frame and no figure. The regenerated instruments report again once they land, so - the figures settle on the exported form. - """ - channels = self._current_generators() - if channels is None: - return - - self.call( - self.on_view_changed, - self._build_view_model({**channels, channel_name: self._as_regenerated(channel_name, features)}), - ) - - def _as_regenerated(self, channel_name: ChannelName, features: Features) -> Features: - """The envelopes a channel holds once the regeneration has rebuilt it from its latest edit. - - A channel the edit silenced rests through every frame, which the document stands by, - describing no frame. - """ - if channel_name in self._silenced: - return features.leave_to_channel(features.envelopes) - - return features - - def _schedule_reconstruction_update( - self, - update: ReconstructionUpdate, - ) -> None: - """Coalesces a burst of edits into the latest pending update, then hands it off promptly. - - The slot keeps only the newest update so events arriving within the short debounce - collapse into one. A dedicated, brief delay keeps the hand-off responsive; the - regeneration service then applies last-wins across whatever it receives, so the final - edit of a continuous drag is always applied. - """ - self._pending_reconstruction_update = update - CallbackQueue.add( - self._on_reconstruction_update_scheduled, - priority=self._scheduling.priorities.schedule, - delay=self._scheduling.delays.reconstruction_update, - ) - - def _on_reconstruction_update_scheduled(self) -> None: - if self._pending_reconstruction_update is None: - return - - channel_name, feature_key, features = self._pending_reconstruction_update - self._pending_reconstruction_update = None - self.call(self.on_reconstruction_instrument_updated, channel_name, feature_key, features) - - def _get_features(self, channel_name: ChannelName) -> Features: - channels = self._current_generators() - assert channels is not None, "A channel edit arrives only while a reconstruction is open" - - return channels[channel_name] diff --git a/src/sampletones_application/logic/reconstruction/manager.py b/src/sampletones_application/logic/reconstruction/manager.py index 2e4ccd7cd..74bb5a6f8 100644 --- a/src/sampletones_application/logic/reconstruction/manager.py +++ b/src/sampletones_application/logic/reconstruction/manager.py @@ -16,7 +16,7 @@ from sampletones_shared.logger import logger from sampletones_shared.types.callback import VoidCallback from sampletones_shared.utils.callbacks import CallbackMixin -from sampletones_shared.utils.system.paths import first_missing +from sampletones_shared.utils.system.paths import first_missing, is_same_path from sampletones_shared.utils.system.reveal.selection import open_paths_in_explorer @@ -24,10 +24,11 @@ class ReconstructionManager(CallbackMixin): """ The single authority on which reconstruction is currently loaded. - - It abstracts two loading modes — file-backed and in-memory — behind the + - It abstracts two loading modes — file-backed and a project sample's — behind the same interface. - - For the in-memory case, edits mutate the original object by identity; - no copy is made. + - A project sample is known by its voice id, which the project keeps across a history + restore and a reopened file, so whoever follows the project finds the voice the open + document is. - Dirty and load state are tracked by a separate session object. """ @@ -36,6 +37,7 @@ def __init__(self, *, scheduling: SchedulingBehavior) -> None: self._session: ReconstructionSession = ReconstructionSession() self._current_reconstruction: Optional[ReconstructionData] = None self._current_features: Optional[ChannelEnvelopesViewModel] = None + self._voice_id: Optional[str] = None self._listening: StemListening = StemListening() self.on_reconstruction_loaded: Optional[VoidCallback] = None @@ -47,7 +49,7 @@ def session(self) -> ReconstructionSession: def load_reconstruction(self, path: Path) -> None: logger.info(f"Loading reconstruction: {logger.format_path(path)}") - self._adopt_reconstruction(ReconstructionData.load(path)) + self._adopt_reconstruction(ReconstructionData.load(path), voice_id=None) self._session.mark_loaded(path.name) self.call(self.on_reconstruction_loaded) logger.info(f"Reconstruction {logger.format_path(path)} loaded successfully") @@ -57,29 +59,38 @@ def load_reconstruction_object( reconstruction: Reconstruction, *, name: str, + voice_id: str, ) -> None: - """Loads an in-memory reconstruction (e.g. a project sample's) for editing. + """Loads a project sample's reconstruction for editing. - Mirrors :meth:`load_reconstruction` for an object already in memory, so edits - made in the reconstruction tab mutate the same instance the caller holds. The - display name is supplied by the caller, since a detached reconstruction has no - source path to derive it from. + Mirrors :meth:`load_reconstruction` for an object already in memory. The display name is + supplied by the caller, since a detached reconstruction has no source path to derive it + from. The document remembers the sample's voice id, which is how an edit finds the sample + to write back into and how a project change finds the voice the tab shows. """ self._adopt_reconstruction( ReconstructionData.from_reconstruction(reconstruction, name=name), + voice_id=voice_id, ) self._session.mark_loaded(name) self.call(self.on_reconstruction_loaded) - def _adopt_reconstruction(self, reconstruction_data: ReconstructionData) -> None: + def _adopt_reconstruction( + self, + reconstruction_data: ReconstructionData, + *, + voice_id: Optional[str], + ) -> None: """Makes ``reconstruction_data`` the open document and refreshes its derived state. The reader's listening choice and the cached features track whichever reconstruction is open, so every rebinding funnels through here to recompute them in one place. The listening is carried onto the new record before the features are read, so the envelopes - answer for the part the reader is listening to as it now stands. + answer for the part the reader is listening to as it now stands. ``voice_id`` names the + project sample the document is, and ``None`` a standalone document. """ self._current_reconstruction = reconstruction_data + self._voice_id = voice_id self._listening.adopt(reconstruction_data.reconstruction.stems_data) self._load_reconstruction_features() @@ -130,14 +141,18 @@ def save_reconstruction_as(self, filepath: Path) -> None: The write happens first; on success the open document rebinds to an independent, file-backed copy anchored at ``filepath``. A reconstruction that was a project sample is - thereby severed from the project: the copy is a distinct object, so later edits reach only - the saved file and leave the sample intact. A failed write leaves the open document as is. + thereby severed from the project: the copy is a distinct object that names no voice, so + later edits reach only the saved file and leave the sample intact. A failed write leaves + the open document as it was, still the voice it was. """ if self._current_reconstruction is None: return self._write_to_file(self._current_reconstruction.reconstruction, filepath) - self._adopt_reconstruction(self._current_reconstruction.detached_copy(filepath)) + self._adopt_reconstruction( + self._current_reconstruction.detached_copy(filepath), + voice_id=None, + ) self._session.mark_saved(self._current_reconstruction.name) @staticmethod @@ -148,10 +163,9 @@ def _write_to_file(reconstruction: Reconstruction, filepath: Path) -> None: def detach_current_reconstruction(self) -> None: """Re-binds the open reconstruction to its detached, in-memory form. - Adding a file-backed reconstruction to the sequencer turns it into a project sample: its - source audio is detached and it maps to no standalone file. The open document adopts that - form so the reconstruction view reflects the owned sample, keeping the same reconstruction - object so live editing continues. + Removing the file a standalone document was loaded from leaves the document with no file + of its own, so it stays open in memory until the reader saves it elsewhere. It keeps the + same reconstruction object, so live editing continues. """ if self._current_reconstruction is None: return @@ -166,14 +180,16 @@ def detach_current_reconstruction(self) -> None: def apply_edited(self, reconstruction: Reconstruction) -> None: """Adopts a reconstruction an edit produced. - The open document rebinds to the fresh reconstruction object so the editor - and any owning project sample continue to share one identity, while the - previous object is left untouched for the history to retain. + The open document rebinds to the fresh reconstruction object and stays the voice it was, + while the previous object is left untouched for the history to retain. """ if self._current_reconstruction is None: return - self._adopt_reconstruction(self._current_reconstruction.with_reconstruction(reconstruction)) + self._adopt_reconstruction( + self._current_reconstruction.with_reconstruction(reconstruction), + voice_id=self._voice_id, + ) def mark_updated(self) -> None: self._session.mark_updated() @@ -181,6 +197,7 @@ def mark_updated(self) -> None: def close_reconstruction(self) -> None: self._current_reconstruction = None self._current_features = None + self._voice_id = None self._listening.release() self._session.mark_closed() CallbackQueue.add( @@ -212,6 +229,16 @@ def current_reconstruction(self) -> Optional[ReconstructionData]: def current_features(self) -> Optional[ChannelEnvelopesViewModel]: return self._current_features + @property + def voice_id(self) -> Optional[str]: + """The project voice the open document is, or ``None`` for a standalone document or none.""" + return self._voice_id + + @property + def is_project_sample(self) -> bool: + """Whether the open document is a sample of the project, whose edits belong to the project.""" + return self._voice_id is not None + @property def listening(self) -> StemListening: """Which recordings of the open document the reader is listening to.""" @@ -235,6 +262,15 @@ def filepath(self) -> Optional[Path]: def is_file_backed(self) -> bool: return self.filepath is not None + def is_backed_by(self, path: Path) -> bool: + """Whether the open document stands for the file at ``path``, however the path is spelled. + + A conversion can write over the file the open document was loaded from or last saved to, + and removing a file from the browser can take the open document's own away. + """ + filepath = self.filepath + return filepath is not None and is_same_path(filepath, path) + @property def source_paths(self) -> Tuple[Path, ...]: if self._current_reconstruction is None: diff --git a/src/sampletones_application/logic/reconstruction/reconstruction.py b/src/sampletones_application/logic/reconstruction/reconstruction.py index 0c1654b06..d076b427d 100644 --- a/src/sampletones_application/logic/reconstruction/reconstruction.py +++ b/src/sampletones_application/logic/reconstruction/reconstruction.py @@ -253,28 +253,6 @@ def set_audio_source(self, audio_source: AudioSourceType) -> None: self._emit_audio_data() self.call(self.on_waveform_source_changed, audio_source) - def set_nes_frequency(self, nes_frequency: int) -> None: - """Retunes the open reconstruction to ``nes_frequency`` and re-answers every reading of it. - - The instructions carry over and the audio is re-timed to the new frame length, so the - waveform, the playback and an export follow the new rate. The waveform's view re-fits to - the retuned length, taking the longer of it and the original audio where the document - keeps one, since the old view answers to a length the audio no longer has. The change is - an unsaved edit of the document, like any other. - """ - reconstruction_data = self._reconstruction_data - if not reconstruction_data: - return - - reconstruction = reconstruction_data.reconstruction - retuned = reconstruction.with_nes_frequency(nes_frequency) - if retuned is reconstruction: - return - - self._reconstruction_manager.apply_edited(retuned) - self.update_reconstruction(refit_waveform=True) - self._reconstruction_manager.mark_updated() - def set_selected_channels(self, channels: List[ChannelName]) -> None: """Adopts the reader's channel choice, which the stems list reports as muted columns.""" self._selected_channels = channels diff --git a/src/sampletones_application/logic/reconstruction/rewrites/__init__.py b/src/sampletones_application/logic/reconstruction/rewrites/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_application/logic/reconstruction/rewrites/queue.py b/src/sampletones_application/logic/reconstruction/rewrites/queue.py new file mode 100644 index 000000000..6e69f08e0 --- /dev/null +++ b/src/sampletones_application/logic/reconstruction/rewrites/queue.py @@ -0,0 +1,309 @@ +from dataclasses import dataclass, replace +from typing import Callable, List, Optional + +from sampletones_application.logic.reconstruction.edit import ( + ChannelEdit, + ReconstructionEdit, + Retune, + StemRemoval, +) +from sampletones_application.logic.reconstruction.manager import ReconstructionManager +from sampletones_application.logic.reconstruction.rewrites.regeneration import ( + RegenerationServiceProtocol, +) +from sampletones_application.logic.reconstruction.rewrites.steps import ( + AfterEdits, + ChannelChange, + RateChange, + Rewrite, + StemRemovalRequest, +) +from sampletones_application.services.regeneration.result import ( + RegeneratedInstrument, + RegenerationResult, +) +from sampletones_application.services.result import ServiceError, ServiceSuccess +from sampletones_application.view_model.reconstruction.envelopes import ( + ChannelEnvelopesViewModel, +) +from sampletones_core.reconstructions import Reconstruction +from sampletones_core.reconstructions.reconstruction.stems.removal import ( + can_remove_stem, + without_stem, +) +from sampletones_shared.logger import logger +from sampletones_shared.types.callback import VoidCallback +from sampletones_shared.utils.callbacks import CallbackMixin + + +@dataclass(frozen=True) +class Rebuild: + """A channel change the regeneration is rebuilding, beside the document it started from. + + Attributes: + change: What the reader moved. + base: The document the rebuild started from, or ``None`` once an outside replacement put + that document away. + """ + + change: ChannelChange + base: Optional[Reconstruction] + + def lands_on(self, reconstruction: Optional[Reconstruction]) -> bool: + """Whether the result belongs to ``reconstruction``, the document it was computed from.""" + return self.base is not None and self.base is reconstruction + + +class ReconstructionRewrites(CallbackMixin): + """The steps that change the open document, taken one at a time in the order the reader asked. + + Every edit of the Reconstructions tab's document passes through here: a channel the reader + moved, a recording taken out, a new NES frequency, and a gesture that reads or puts away the + whole document. Each step reads the document the step before it left, so one edit never + overwrites another and a rebuild always starts from what stands. + + Channel changes wait while a rebuild runs, and a change joins the one waiting at the end of the + line when both move the same channel, so a drag collapses into the place it ended. A rebuild's + result lands only on the document it was computed from. A removal or a whole-document gesture + changes what the panel was drawn on, so a channel change asked for while one waits is refused, + and the panel is redrawn once the line empties. + + The hooks carry the outcome to whoever shows the document: ``on_edit`` with each edit that + lands, ``on_dropped`` when the panel draws a change that will never land, ``on_failed`` with a + rebuild's failure, and ``on_busy_changed`` as the line fills and empties. + """ + + def __init__( + self, + reconstruction_manager: ReconstructionManager, + regeneration: RegenerationServiceProtocol, + ) -> None: + self._manager = reconstruction_manager + self._regeneration = regeneration + self._waiting: List[Rewrite] = [] + self._running: Optional[Rebuild] = None + self._redraw_owed: bool = False + self._reported_busy: bool = False + + self.on_edit: Optional[Callable[[ReconstructionEdit], None]] = None + self.on_dropped: Optional[VoidCallback] = None + self.on_failed: Optional[Callable[[Exception], None]] = None + self.on_busy_changed: Optional[Callable[[bool], None]] = None + + regeneration.subscribe(self._on_result) + + @property + def is_busy(self) -> bool: + """Whether a rebuild runs or a step waits, which is the span the open document is being rewritten.""" + return self._running is not None or bool(self._waiting) + + def request(self, rewrite: Rewrite) -> None: + """Puts a step at the end of the line, and takes it at once where nothing runs before it. + + A channel change joins a waiting change of the same channel, and a new rate replaces a + waiting one, so what the reader asked for last is what lands. + """ + match rewrite: + case ChannelChange() as change: + self._enqueue_change(change) + case RateChange() as rate_change: + self._enqueue_rate(rate_change) + case StemRemovalRequest() | AfterEdits(): + self._waiting.append(rewrite) + + self._advance() + self._settle() + + def drop(self) -> None: + """Lets go of the edits meant for a document an outside replacement puts away. + + The channel changes, removals and rates waiting leave the line, and the running rebuild's + result no longer lands. A whole-document gesture the reader asked for keeps its place, so a + save, a load or an undo still happens, in order, on the document now open. The replacement + draws the document it puts in place, which answers a redraw still owed. + """ + self._waiting = [step for step in self._waiting if isinstance(step, AfterEdits)] + self._redraw_owed = False + if self._running is not None: + self._running = replace(self._running, base=None) + + self._settle() + + def drawn(self, envelopes: ChannelEnvelopesViewModel) -> ChannelEnvelopesViewModel: + """The document's envelopes with every change still on its way written over them, in order. + + The instruments panel draws these, so what the reader sees is what the document will hold + once the line empties. A rebuild whose document was put away writes nothing. + + Args: + envelopes: The envelopes the open document holds. + """ + changes = self._pending_changes() + if not changes: + return envelopes + + channels = dict(envelopes.channels) + for change in changes: + channels[change.channel_name] = change.rebased(channels[change.channel_name]) + + return ChannelEnvelopesViewModel(channels=channels, ownership=envelopes.ownership) + + def _pending_changes(self) -> List[ChannelChange]: + """The channel changes on their way to the open document, the running one first.""" + changes: List[ChannelChange] = [] + if self._running is not None and self._running.lands_on(self._manager.reconstruction): + changes.append(self._running.change) + + changes.extend(step for step in self._waiting if isinstance(step, ChannelChange)) + return changes + + @property + def _reshaping_waits(self) -> bool: + """Whether a removal or a whole-document gesture waits, either of which reshapes what the panel drew on.""" + return any(isinstance(step, (StemRemovalRequest, AfterEdits)) for step in self._waiting) + + def _enqueue_change(self, change: ChannelChange) -> None: + if self._reshaping_waits: + logger.info(f"A change of {change.channel_name} was drawn before a step that reshapes the document") + self._redraw_owed = True + return + + match self._waiting[-1] if self._waiting else None: + case ChannelChange() as tail if tail.channel_name == change.channel_name: + self._waiting[-1] = tail.merged(change) + case _: + self._waiting.append(change) + + def _enqueue_rate(self, rate_change: RateChange) -> None: + match self._waiting[-1] if self._waiting else None: + case RateChange(): + self._waiting[-1] = rate_change + case _: + self._waiting.append(rate_change) + + def _advance(self) -> None: + """Takes the waiting steps in order until a rebuild runs or the line empties.""" + while self._running is None and self._waiting: + self._take(self._waiting.pop(0)) + + def _take(self, step: Rewrite) -> None: + match step: + case ChannelChange() as change: + self._rebuild(change) + case StemRemovalRequest() as removal: + self._remove(removal) + case RateChange() as rate_change: + self._retune(rate_change) + case AfterEdits(gesture=gesture): + gesture() + + def _rebuild(self, change: ChannelChange) -> None: + """Starts the rebuild of a changed channel from the document and the listening as they stand. + + The rebuild is marked running before it starts, since a result can arrive before the start + returns. + """ + reconstruction = self._manager.reconstruction + envelopes = self._manager.current_features + if reconstruction is None or envelopes is None: + logger.info(f"A change of {change.channel_name} found no document open") + return + + self._running = Rebuild(change=change, base=reconstruction) + self._regeneration.start( + reconstruction, + change.channel_name, + change.rebased(envelopes[change.channel_name]), + self._manager.listening.heard_on(change.channel_name), + ) + + def _remove(self, removal: StemRemovalRequest) -> None: + """Takes a recording out, where a step before it left the recording and another beside it.""" + reconstruction = self._manager.reconstruction + if reconstruction is None or not can_remove_stem(reconstruction, removal.stem_id): + logger.info(f"The removal of stem {removal.stem_id} no longer applies to the open document") + return + + self.call( + self.on_edit, + StemRemoval( + reconstruction=without_stem(reconstruction, removal.stem_id), + stem_name=removal.stem_name, + ), + ) + + def _retune(self, rate_change: RateChange) -> None: + """Re-times the document to the rate asked for, where it runs at another one.""" + reconstruction = self._manager.reconstruction + if reconstruction is None: + logger.info(f"A rate of {rate_change.nes_frequency} Hz found no document open") + return + + retuned = reconstruction.with_nes_frequency(rate_change.nes_frequency) + if retuned is reconstruction: + logger.info(f"The open document already runs at {rate_change.nes_frequency} Hz") + return + + self.call( + self.on_edit, + Retune( + reconstruction=retuned, + nes_frequency=rate_change.nes_frequency, + ), + ) + + def _on_result(self, result: RegenerationResult) -> None: + """Lands a finished rebuild on the document it was computed from, then takes the next step. + + Raises: + RuntimeError: If a result arrives while no rebuild runs. + """ + rebuild = self._running + if rebuild is None: + raise RuntimeError("A regeneration result arrived while no rebuild was running") + + self._running = None + match result: + case ServiceSuccess(value=outcome): + self._land(rebuild, outcome) + case ServiceError(exception=exception): + self.call(self.on_failed, exception) + self._let_go(rebuild) + + self._advance() + self._settle() + + def _land(self, rebuild: Rebuild, outcome: RegeneratedInstrument) -> None: + if not rebuild.lands_on(self._manager.reconstruction): + logger.info(f"A rebuild of {rebuild.change.channel_name} finished for a document put away since") + self._let_go(rebuild) + return + + self.call( + self.on_edit, + ChannelEdit( + reconstruction=outcome.reconstruction, + channel_name=rebuild.change.channel_name, + feature_key=rebuild.change.feature_key, + ), + ) + + def _let_go(self, rebuild: Rebuild) -> None: + """Redraws the panel where it still draws a change that will never land. + + A rebuild an outside replacement dropped needs nothing, since the replacement drew the + document it put in place. + """ + if rebuild.base is not None: + self.call(self.on_dropped) + + def _settle(self) -> None: + """Pays a redraw owed once the line empties, and reports the line filling or emptying.""" + if not self.is_busy and self._redraw_owed: + self._redraw_owed = False + self.call(self.on_dropped) + + busy = self.is_busy + if busy != self._reported_busy: + self._reported_busy = busy + self.call(self.on_busy_changed, busy) diff --git a/src/sampletones_application/logic/reconstruction/rewrites/regeneration.py b/src/sampletones_application/logic/reconstruction/rewrites/regeneration.py new file mode 100644 index 000000000..46cc25c3a --- /dev/null +++ b/src/sampletones_application/logic/reconstruction/rewrites/regeneration.py @@ -0,0 +1,24 @@ +from typing import AbstractSet, Callable, Protocol + +from sampletones_application.services.regeneration.result import RegenerationResult +from sampletones_core.constants.enums import ChannelName +from sampletones_core.exporters import Features +from sampletones_core.reconstructions import Reconstruction + + +class RegenerationServiceProtocol(Protocol): + """The slice of the regeneration service the rewrites drive. + + Typing the collaborator structurally keeps the logic layer bound to the service's result + contract alone; the composition root supplies the real service. + """ + + def subscribe(self, handler: Callable[[RegenerationResult], None]) -> None: ... + + def start( + self, + reconstruction: Reconstruction, + channel_name: ChannelName, + features: Features, + heard: AbstractSet[int], + ) -> None: ... diff --git a/src/sampletones_application/logic/reconstruction/rewrites/steps.py b/src/sampletones_application/logic/reconstruction/rewrites/steps.py new file mode 100644 index 000000000..dc83b7736 --- /dev/null +++ b/src/sampletones_application/logic/reconstruction/rewrites/steps.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Mapping, Optional, TypeAlias, Union + +from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.exporters import Features +from sampletones_core.features.envelope import Envelope +from sampletones_shared.types.callback import VoidCallback + + +@dataclass(frozen=True) +class ChannelChange: + """What a reader moved on one channel of the instruments panel. + + A change carries the dimensions the reader moved alone, and at its turn it is written over the + envelopes the document then holds. The rest of the channel is read at that turn, so what a step + before it rebuilt stands, and a frame a removal released keeps resting. + + Attributes: + channel_name: The channel the reader moved. + feature_key: The dimension moved last, which the history names the edit by. + envelopes: Each dimension moved, as the reader left it. + initial_pitch: The pitch the arpeggio is measured against, where the reader moved it. + """ + + channel_name: ChannelName + feature_key: FeatureKey + envelopes: Mapping[FeatureKey, Envelope[int]] + initial_pitch: Optional[int] + + def merged(self, later: ChannelChange) -> ChannelChange: + """This change followed by ``later``, as one change carrying what both moved. + + A dimension both moved takes the later value, and so does the pitch where the later change + moved it, so a drag collapses into the place it ended. + + Raises: + ValueError: If ``later`` moves another channel. + """ + if later.channel_name != self.channel_name: + raise ValueError(f"A change of {later.channel_name} merges into a change of that channel alone") + + return ChannelChange( + channel_name=self.channel_name, + feature_key=later.feature_key, + envelopes={**self.envelopes, **later.envelopes}, + initial_pitch=self.initial_pitch if later.initial_pitch is None else later.initial_pitch, + ) + + def rebased(self, features: Features) -> Features: + """The channel's envelopes as ``features`` holds them, with what this change moved written over them.""" + rebased = ( + features + if self.initial_pitch is None + else features.model_copy(update={"initial_pitch": self.initial_pitch}) + ) + for feature_key, envelope in self.envelopes.items(): + rebased = rebased.with_envelope(feature_key, envelope) + + return rebased + + +@dataclass(frozen=True) +class StemRemovalRequest: + """A reader taking a recording out of the document, named as the history reports it.""" + + stem_id: int + stem_name: str + + +@dataclass(frozen=True) +class RateChange: + """The document re-timed to another NES frequency.""" + + nes_frequency: int + + +@dataclass(frozen=True) +class AfterEdits: + """A gesture that reads or puts away the whole document, run once the edits before it have landed.""" + + gesture: VoidCallback + + +Rewrite: TypeAlias = Union[ChannelChange, StemRemovalRequest, RateChange, AfterEdits] diff --git a/src/sampletones_application/services/regeneration/result.py b/src/sampletones_application/services/regeneration/result.py index 0ec931a67..a341bfd05 100644 --- a/src/sampletones_application/services/regeneration/result.py +++ b/src/sampletones_application/services/regeneration/result.py @@ -2,29 +2,26 @@ from typing import Union from sampletones_application.services.result import ( - ServiceCanceled, ServiceError, ServiceSuccess, ) -from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.reconstructions import Reconstruction @dataclass(frozen=True) class RegeneratedInstrument: - """A regeneration result paired with the generator and feature that changed. + """A reconstruction with one channel rebuilt from the envelopes the regeneration was handed. - Carrying the request context alongside the fresh reconstruction lets the - history record which channel and feature an edit touched. + The caller keeps what it asked for, so the result carries the rebuilt document alone. + + Attributes: + reconstruction: The fresh reconstruction carrying the rebuilt channel. """ reconstruction: Reconstruction - channel_name: ChannelName - feature_key: FeatureKey RegenerationResult = Union[ ServiceSuccess[RegeneratedInstrument], ServiceError, - ServiceCanceled, ] diff --git a/src/sampletones_application/services/regeneration/service.py b/src/sampletones_application/services/regeneration/service.py index 784d336fa..5040f4154 100644 --- a/src/sampletones_application/services/regeneration/service.py +++ b/src/sampletones_application/services/regeneration/service.py @@ -1,3 +1,4 @@ +from functools import partial from typing import AbstractSet, List, cast from sampletones_application.services.base import ServiceBase @@ -5,13 +6,9 @@ RegeneratedInstrument, RegenerationResult, ) -from sampletones_application.services.result import ( - ServiceCanceled, - ServiceError, - ServiceSuccess, -) -from sampletones_application.utils.parallelization.coalescing import LatestWinsExecutor -from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_application.services.result import ServiceError, ServiceSuccess +from sampletones_application.utils.parallelization.thread import SingleThreadExecutor +from sampletones_core.constants.enums import ChannelName from sampletones_core.exporters import CHANNEL_TO_EXPORTER_MAP, Features from sampletones_core.instructions import InstructionUnion from sampletones_core.reconstructions import Reconstruction @@ -29,55 +26,47 @@ class RegenerationService(ServiceBase[RegenerationResult]): the edited reconstruction in while any history snapshot that shares the prior object stays valid. - Requests are serialized on a :class:`LatestWinsExecutor`: while a job runs, further - requests coalesce to the latest one, so a continuous stream of edits collapses to a - single applied result — the final value. ``is_running`` reports whether that worker is - still busy, letting the view fade the reconstruction for the whole busy span. + One job runs at a time on a single worker thread. Its caller starts the next job once the + previous result has arrived, so each rebuild starts from the document the one before it left. """ def __init__(self, priority: int = 0) -> None: super().__init__(priority) - self._executor = LatestWinsExecutor() - self._canceled: bool = False + self._executor = SingleThreadExecutor() def start( self, reconstruction: Reconstruction, channel_name: ChannelName, - feature_key: FeatureKey, features: Features, heard: AbstractSet[int], - ) -> bool: - if self._canceled: - return False + ) -> None: + """Rebuilds ``channel_name`` of ``reconstruction`` from ``features`` on the worker thread. - return self._executor.submit( - lambda: self._run( + Args: + reconstruction: The document the rebuild starts from, which stays as it is. + channel_name: The channel rebuilt. + features: The envelopes the channel is rebuilt from. + heard: The recordings whose frames the rebuild writes. + """ + self._executor.execute( + partial( + self._run, reconstruction, channel_name, - feature_key, features, heard, - ) + ), + wait=True, ) - def is_running(self) -> bool: - return self._executor.is_running - - def cancel(self) -> None: - self._canceled = True - def _run( self, reconstruction: Reconstruction, channel_name: ChannelName, - feature_key: FeatureKey, features: Features, heard: AbstractSet[int], ) -> None: - if self._canceled: - self._emit(ServiceCanceled()) - return try: exporter_class = CHANNEL_TO_EXPORTER_MAP[channel_name] instructions = cast( @@ -93,14 +82,6 @@ def _run( features.held_features, heard=heard, ) - self._emit( - ServiceSuccess( - value=RegeneratedInstrument( - reconstruction=updated, - channel_name=channel_name, - feature_key=feature_key, - ) - ) - ) + self._emit(ServiceSuccess(value=RegeneratedInstrument(reconstruction=updated))) except Exception as exception: # pylint: disable=broad-exception-caught self._emit(ServiceError(exception=exception)) diff --git a/src/sampletones_application/services/retune/sample.py b/src/sampletones_application/services/retune/sample.py index 26628ec0d..2010487c3 100644 --- a/src/sampletones_application/services/retune/sample.py +++ b/src/sampletones_application/services/retune/sample.py @@ -8,8 +8,16 @@ class RetunedSample: """A sample's reconstruction re-synthesized to a new NES frequency. Carries the ``voice_id`` so the caller can swap the retuned reconstruction into - the right project sample as each result arrives. + the right project sample as each result arrives, and the ``source`` it was retuned + from, so the caller can tell whether the sample still holds it. + + Attributes: + voice_id: The sample the retune is for. + reconstruction: The reconstruction at the new frequency. + source: The reconstruction the sample held when the batch started, which the retune + was computed from. """ voice_id: str reconstruction: Reconstruction + source: Reconstruction diff --git a/src/sampletones_application/services/retune/service.py b/src/sampletones_application/services/retune/service.py index ec59998ae..521fc5233 100644 --- a/src/sampletones_application/services/retune/service.py +++ b/src/sampletones_application/services/retune/service.py @@ -44,6 +44,7 @@ def _run(self, targets: List[RetuneTarget], nes_frequency: int) -> None: value=RetunedSample( voice_id=voice_id, reconstruction=retuned, + source=reconstruction, ) ) ) diff --git a/src/sampletones_application/tags/general.py b/src/sampletones_application/tags/general.py index 71322d010..756061559 100644 --- a/src/sampletones_application/tags/general.py +++ b/src/sampletones_application/tags/general.py @@ -506,6 +506,12 @@ Widget.DIALOG, "reconstruction_saved", ) +TAG_GLOBAL_DIALOG_RECONSTRUCTION_REPLACED = TagName( + Page.GLOBAL, + Panel.IMPLICIT, + Widget.DIALOG, + "reconstruction_replaced", +) TAG_GLOBAL_DIALOG_ABOUT = TagName( Page.GLOBAL, Panel.IMPLICIT, diff --git a/src/sampletones_application/ui/elements/graphs/waveform.py b/src/sampletones_application/ui/elements/graphs/waveform.py index e61ddaf73..eebd88568 100644 --- a/src/sampletones_application/ui/elements/graphs/waveform.py +++ b/src/sampletones_application/ui/elements/graphs/waveform.py @@ -522,7 +522,6 @@ def load_waveform_data( waveform_data: WaveformData, selected_channels: Optional[List[ChannelName]] = None, ) -> None: - self._reconstruction_dimmed = False self.clear_layers() self.current_data = waveform_data self._sample_rate = waveform_data.sample_rate diff --git a/src/sampletones_application/ui/elements/seeded.py b/src/sampletones_application/ui/elements/seeded.py index 2d6b44a1f..18f66dd1b 100644 --- a/src/sampletones_application/ui/elements/seeded.py +++ b/src/sampletones_application/ui/elements/seeded.py @@ -1,6 +1,8 @@ from abc import ABC, abstractmethod from typing import Any, Generic, Optional, TypeVar +import dearpygui.dearpygui as dpg + from sampletones_application.layout.primitives import DialogGeometry from sampletones_application.ui.elements.dialog import GUIDialogWindow from sampletones_application.utils.gui.keyboard import KeyRouter @@ -47,9 +49,14 @@ def prepare(self, *_args: Any, **_kwargs: Any) -> None: """The values drawn were seeded by :meth:`open` before the tree rebuilt.""" def update_view(self, view_model: ViewModel) -> None: - """Re-seeds the open window's controls from where its subject stands.""" + """Re-seeds the window from where its subject stands, re-drawing the controls it stands with. + + A window still waiting for the screen keeps the newest seed, which it is built from when it + opens. + """ self._view_model = view_model - self._render() + if dpg.does_item_exist(self.tag): + self._render() @property def view_model(self) -> ViewModel: diff --git a/src/sampletones_application/ui/elements/stems/row.py b/src/sampletones_application/ui/elements/stems/row.py index 74b04fa80..7bed61f4f 100644 --- a/src/sampletones_application/ui/elements/stems/row.py +++ b/src/sampletones_application/ui/elements/stems/row.py @@ -159,16 +159,17 @@ def repaint(self, row: StemRowViewModel, view_model: StemsListViewModel) -> None dpg_configure_item(self._tags.row(row.key, SUF_TWISTY), enabled=live) def releasable(self, view_model: StemsListViewModel, row: Optional[StemRowViewModel] = None) -> bool: - """Whether a row may leave, which a list holding on to its last one answers by its count. + """Whether a row may leave, which a list holding on to its last recording answers by its count. The button on the row reads this, and so does every gesture reaching removal from outside the row — a key press, a menu item — so one rule answers them all. A row naming something - a removal stands outside of answers for itself. + a removal stands outside of answers for itself, and it takes no part in the count, so the + last recording of a document the reader has written into keeps its place. """ if row is not None and not row.releasable: return False - return view_model.row_count > 1 or not self._offer.keeps_last_row + return view_model.releasable_count > 1 or not self._offer.keeps_last_row def _repaint_swatch(self, row: StemRowViewModel) -> None: """Keeps the square reading as the place its recording now holds on the record.""" diff --git a/src/sampletones_application/ui/elements/window.py b/src/sampletones_application/ui/elements/window.py index a78009cdc..83ec74ca9 100644 --- a/src/sampletones_application/ui/elements/window.py +++ b/src/sampletones_application/ui/elements/window.py @@ -1,5 +1,6 @@ from abc import ABC, abstractmethod from contextlib import contextmanager +from functools import partial from typing import Any, Iterator, Optional import dearpygui.dearpygui as dpg @@ -10,7 +11,7 @@ from sampletones_application.ui.themes.registry import ThemeRegistry from sampletones_application.utils.gui.align import center_when_settled, viewport_center from sampletones_application.utils.gui.dpg import dpg_configure_item, dpg_delete_item -from sampletones_application.utils.gui.frame import FrameCallbackManager +from sampletones_application.utils.gui.modal_queue import ModalQueue from sampletones_application.utils.placement import centered_position from sampletones_shared.types.callback import VoidCallback @@ -25,15 +26,19 @@ class GUIWindow(GUIPanel, ABC): down. Each rebuild binds the elevated dialog-window theme so the window floats above the app with an accent border and title bar. - A dialog that raises another modal — a prompt, a countdown — hands the screen - over with ``yield_to`` and takes it back with ``resume``, which is what keeps - the two from competing for the one modal DearPyGui carries at a time. The - position a window was placed at is its own from then on, so the return brings - it back where it stood. - A window claims the screen while it stands, so the reader answers it before going on. One reporting work already under way clears ``_claims_the_screen`` instead, which leaves the rest - of the interface live beside it. + of the interface live beside it. DearPyGui shows one modal window at a time, so a window + claiming the screen opens through :class:`ModalQueue`: at once where the screen is free, and + once the conversation holding it has ended otherwise. + + A dialog that raises another modal — a prompt, a countdown — hands the screen + over with ``yield_to`` and takes it back with ``resume``. The position a window was + placed at is its own from then on, so the return brings it back where it stood. + + A dialog that closes on its answer leaves with ``_leave_then``: it goes off + screen, and the answer runs a frame later. Whatever the answer raises — a + question of its own, an error — then opens alone and holds the keyboard. """ _claims_the_screen: bool = True @@ -45,22 +50,37 @@ def __init__(self, tag: str, geometry: DialogGeometry) -> None: def yield_to(self, raise_modal: VoidCallback) -> None: """Steps off screen and runs ``raise_modal`` a frame later, so what it raises can open. - DearPyGui carries one modal at a time: a modal built while another one is still on - screen opens as a hidden window nobody can reach. This window goes off screen first - and the frame it was drawn in finishes, leaving the new modal alone on screen. The - widget tree stays where it is, so whatever is being edited here survives the visit - and :meth:`resume` brings it back untouched. + The window keeps the screen while it stands aside, so the modal it raises opens ahead of + any waiting in line. The widget tree stays where it is, so whatever is being edited here + survives the visit and :meth:`resume` brings it back untouched. """ dpg_configure_item(self.tag, show=False) - FrameCallbackManager.set_frame_callback(raise_modal) + ModalQueue.step_aside(self.tag) + ModalQueue.hand_off(raise_modal) def resume(self) -> None: """Comes back on screen once the modal this window yielded to is gone. - The return waits a frame for the same reason the hand-off does: the modal being - dismissed still holds the screen for the frame it is dismissed in. + The modal being dismissed still holds the screen for the frame it is dismissed in, so the + return comes on a later frame. + """ + ModalQueue.come_back(self.tag, lambda: dpg_configure_item(self.tag, show=True)) + + def _leave_then(self, answer: VoidCallback) -> None: + """Deletes this appearance and runs ``answer`` a frame later, once the screen is clear. + + The tree goes first, and with it the keyboard claim of this appearance, so a prompt the + answer raises is the only modal on screen and the one the keyboard reaches. The answer + carries on the conversation this window held the screen for, so what it raises opens ahead + of any modal waiting in line. What the answer reads from the window, such as a ticked box + or a form field, is read before this call. Only the first answer given while the window + stands runs: a second click reaches a window that has already left. """ - FrameCallbackManager.set_frame_callback(lambda: dpg_configure_item(self.tag, show=True)) + if not dpg.does_item_exist(self.tag): + return + + self.hide() + ModalQueue.hand_off(answer) @contextmanager def dialog_window( @@ -96,6 +116,18 @@ def dialog_window( yield def show(self, *args: Any, **kwargs: Any) -> None: + """Raises this window: at once, or once the screen is free for it where it claims the screen. + + Showing a window again puts the newer appearance in place of the one standing or waiting. + """ + self.hide() + appear = partial(self._appear, *args, **kwargs) + if self._claims_the_screen: + ModalQueue.open(self.tag, appear) + else: + appear() + + def _appear(self, *args: Any, **kwargs: Any) -> None: """Builds this appearance's tree, places it, and holds it centered as it takes its size. A window stating a height is placed before it is ever drawn, so the first frame carrying @@ -107,7 +139,6 @@ def show(self, *args: Any, **kwargs: Any) -> None: result reaches the screen — including the callback drain that runs between frames, where the frame being waited for is the one this call stands in the way of. """ - self.hide() self.prepare(*args, **kwargs) self.create_window() ThemeRegistry.get(TAG_GLOBAL_THEME_DIALOG_WINDOW).bind_to_item(self.tag) @@ -117,8 +148,10 @@ def show(self, *args: Any, **kwargs: Any) -> None: center_when_settled(self.tag) def hide(self) -> None: + """Takes this window off the screen, or out of the line where it still waits for the screen.""" self._teardown() dpg_delete_item(self.tag) + ModalQueue.leave(self.tag) def _teardown(self) -> None: """Releases resources tied to the current appearance before its tree is deleted. diff --git a/src/sampletones_application/ui/panels/dialogs/audio_settings.py b/src/sampletones_application/ui/panels/dialogs/audio_settings.py index 5617c34b8..0e81f6097 100644 --- a/src/sampletones_application/ui/panels/dialogs/audio_settings.py +++ b/src/sampletones_application/ui/panels/dialogs/audio_settings.py @@ -1,3 +1,4 @@ +from functools import partial from typing import Any, Callable, Dict, List, Optional import dearpygui.dearpygui as dpg @@ -272,8 +273,12 @@ def _refresh_devices(self) -> None: self.call(self.on_refresh_devices) def _commit(self) -> None: + """Reports the chosen device, rate and buffer once the window has left the screen. + + Applying them can fail with a playback error, and that error opens alone once the window + is gone, so the choices are read while the combos still stand. + """ device = self._devices_by_label[dpg.get_value(TAG_SETTINGS_AUDIO_COMBO_DEVICE)] sample_rate = self._sample_rates_by_label[dpg.get_value(TAG_SETTINGS_AUDIO_COMBO_SAMPLE_RATE)] buffer_size = BUFFER_SIZE_ITEMS[dpg.get_value(TAG_SETTINGS_AUDIO_COMBO_BUFFER_SIZE)] - self.call(self.on_commit, device.device_index, sample_rate, buffer_size) - self.hide() + self._leave_then(partial(self.call, self.on_commit, device.device_index, sample_rate, buffer_size)) diff --git a/src/sampletones_application/ui/panels/dialogs/project_properties.py b/src/sampletones_application/ui/panels/dialogs/project_properties.py index 7393a36b7..ab63ae7fe 100644 --- a/src/sampletones_application/ui/panels/dialogs/project_properties.py +++ b/src/sampletones_application/ui/panels/dialogs/project_properties.py @@ -1,3 +1,4 @@ +from functools import partial from typing import Any, Callable, Optional import dearpygui.dearpygui as dpg @@ -253,15 +254,18 @@ def _create_action_buttons(self) -> None: ) def _commit(self) -> None: - self.call( - self.on_commit, - dpg.get_value(TAG_SETTINGS_PROPERTIES_INPUT_TITLE)[:MAX_PROJECT_TITLE_LENGTH], - dpg.get_value(TAG_SETTINGS_PROPERTIES_INPUT_AUTHOR)[:MAX_PROJECT_AUTHOR_LENGTH], - dpg.get_value(TAG_SETTINGS_PROPERTIES_INPUT_COMMENT)[:MAX_PROJECT_COMMENT_LENGTH], - int(clamp_widget_value(TAG_SETTINGS_PROPERTIES_INPUT_FIRST_HIGHLIGHT)), - int(clamp_widget_value(TAG_SETTINGS_PROPERTIES_INPUT_SECOND_HIGHLIGHT)), + """Reports the edited form once the window has left the screen, reading the fields first.""" + self._leave_then( + partial( + self.call, + self.on_commit, + dpg.get_value(TAG_SETTINGS_PROPERTIES_INPUT_TITLE)[:MAX_PROJECT_TITLE_LENGTH], + dpg.get_value(TAG_SETTINGS_PROPERTIES_INPUT_AUTHOR)[:MAX_PROJECT_AUTHOR_LENGTH], + dpg.get_value(TAG_SETTINGS_PROPERTIES_INPUT_COMMENT)[:MAX_PROJECT_COMMENT_LENGTH], + int(clamp_widget_value(TAG_SETTINGS_PROPERTIES_INPUT_FIRST_HIGHLIGHT)), + int(clamp_widget_value(TAG_SETTINGS_PROPERTIES_INPUT_SECOND_HIGHLIGHT)), + ) ) - self.hide() @staticmethod def _label( diff --git a/src/sampletones_application/ui/panels/dialogs/stem_selection.py b/src/sampletones_application/ui/panels/dialogs/stem_selection.py index ae255c469..1acc28212 100644 --- a/src/sampletones_application/ui/panels/dialogs/stem_selection.py +++ b/src/sampletones_application/ui/panels/dialogs/stem_selection.py @@ -1,3 +1,4 @@ +from functools import partial from pathlib import Path from typing import Any, Callable, Final, FrozenSet, List, Optional, Sequence, Tuple @@ -212,9 +213,8 @@ def _fits(self) -> bool: return NO_PICK < len(self._picked) <= self._room def _add(self) -> None: + """Answers with the pick once the question has left the screen, so what follows opens alone.""" if not self._fits: return - picked = list(self._view().picked_paths) - self.hide() - self.call(self._answer, picked) + self._leave_then(partial(self.call, self._answer, list(self._view().picked_paths))) diff --git a/src/sampletones_application/utils/callbacks/gates.py b/src/sampletones_application/utils/callbacks/gates.py new file mode 100644 index 000000000..cbfd9da79 --- /dev/null +++ b/src/sampletones_application/utils/callbacks/gates.py @@ -0,0 +1,51 @@ +from functools import partial +from typing import Callable, ParamSpec, Sequence, TypeVar + +from sampletones_shared.types.callback import VoidCallback + +Gate = Callable[[VoidCallback], None] + +GestureParameters = ParamSpec("GestureParameters") +GestureResult = TypeVar("GestureResult") + + +def pass_gates(gates: Sequence[Gate], arrive: VoidCallback) -> None: + """Runs each gate in turn, and calls ``arrive`` once the last one lets the request through. + + Each gate receives a callback that runs the gates after it. A gate checks what it guards when + it is reached: it calls the callback at once, or asks a question whose answer calls it. An + earlier question is therefore answered before a later gate checks, and a state that cleared + meanwhile asks nothing. A declined question stops the request there. + + Args: + gates: What stands between a request and ``arrive``, in the order the gates are asked. + arrive: What runs once every gate has let the request through. + """ + if not gates: + arrive() + return + + gates[0](partial(pass_gates, gates[1:], arrive)) + + +def gated( + gate: Gate, + gesture: Callable[GestureParameters, GestureResult], +) -> Callable[GestureParameters, None]: + """``gesture`` as a callback that passes ``gate`` before it runs, with the arguments it was called with. + + A menu item, a shortcut or a panel hook takes the callback in the gesture's place, so whatever + reaches the gesture goes through the gate first. The callback discards what the gesture returns. + + Args: + gate: What the gesture waits on. + gesture: What runs once the gate lets it through. + """ + + def call(*args: GestureParameters.args, **kwargs: GestureParameters.kwargs) -> None: + def run() -> None: + gesture(*args, **kwargs) + + gate(run) + + return call diff --git a/src/sampletones_application/utils/gui/dialog_navigation/navigator.py b/src/sampletones_application/utils/gui/dialog_navigation/navigator.py index ba4bb99a2..2e172807c 100644 --- a/src/sampletones_application/utils/gui/dialog_navigation/navigator.py +++ b/src/sampletones_application/utils/gui/dialog_navigation/navigator.py @@ -52,7 +52,7 @@ def dispose(self) -> None: return self._disposed = True - self._router.pop_modal() + self._router.pop_modal(self) def _focus_initial(self) -> None: if dpg.does_item_exist(self._window_tag): diff --git a/src/sampletones_application/utils/gui/dialogs/outcome.py b/src/sampletones_application/utils/gui/dialogs/outcome.py new file mode 100644 index 000000000..f7fda8492 --- /dev/null +++ b/src/sampletones_application/utils/gui/dialogs/outcome.py @@ -0,0 +1,14 @@ +from enum import Enum, auto + + +class SaveOutcome(Enum): + """What a save the reader asked for came to, which says where a save prompt goes next. + + A save prompt runs its save once it has left the screen, so the outcome decides what follows: + a document on disk goes on to what the prompt was guarding, a save the reader called off asks + the question again, and a failed save leaves its error on screen by itself. + """ + + WRITTEN = auto() + CALLED_OFF = auto() + FAILED = auto() diff --git a/src/sampletones_application/utils/gui/dialogs/renderer.py b/src/sampletones_application/utils/gui/dialogs/renderer.py index 51ffb52be..181a350bc 100644 --- a/src/sampletones_application/utils/gui/dialogs/renderer.py +++ b/src/sampletones_application/utils/gui/dialogs/renderer.py @@ -24,6 +24,7 @@ from sampletones_application.ui.elements.fonts.registry import FontRegistry from sampletones_application.ui.elements.path import GUIPathText from sampletones_application.ui.elements.status import GUIStatusBar +from sampletones_application.utils.gui.dialogs.outcome import SaveOutcome from sampletones_application.utils.gui.dialogs.windows.confirmation import ( GUIConfirmationWindow, ) @@ -279,8 +280,9 @@ def show_confirmation( ) -> None: """Modal confirmation. ``on_confirm``/``on_cancel`` run on the respective choice. - The title bar's close button reads as the negative choice, so every way out of the - prompt reaches the caller and a dialog waiting behind it hears the answer. + The title bar's close button and Escape read as the negative choice, so every way out of + the prompt reaches the caller and a dialog waiting behind it hears the answer. The answer + runs a frame after the prompt has left the screen, so a prompt it raises opens alone. ``cancel_label`` names the negative button; it falls back to the shared Cancel label. When ``opt_out_label`` is given, a checkbox is shown; if it is ticked when the user @@ -313,17 +315,19 @@ def show_save_confirmation( tag: str, message: str, title: str, - on_save: Callable[[], bool], + on_save: Callable[[], SaveOutcome], on_confirm: Callback, *, ok_label: str, ) -> None: """Modal save-or-proceed prompt for an unsaved document. - ``on_save`` writes the document and reports whether it completed; the prompt runs - ``on_confirm`` and closes once the save reports success, so a canceled save keeps the - prompt open for another attempt. The middle button discards the pending changes and runs - ``on_confirm`` to proceed, and Cancel — the initially focused button — dismisses the prompt. + Every answer runs once the prompt has left the screen, so whatever it opens stands alone. + ``on_save`` writes the document and reports a :class:`SaveOutcome`: a written document + runs ``on_confirm``, a save the reader called off brings the prompt back with the same + question, and a failed save leaves the error it showed alone on screen. The middle button + discards the pending changes and runs ``on_confirm`` to proceed, and Cancel — the initially + focused button — dismisses the prompt. """ GUISaveConfirmationWindow( tag=get_dialog_tag(tag), diff --git a/src/sampletones_application/utils/gui/dialogs/windows/confirmation.py b/src/sampletones_application/utils/gui/dialogs/windows/confirmation.py index f3adfd2c8..d030ba901 100644 --- a/src/sampletones_application/utils/gui/dialogs/windows/confirmation.py +++ b/src/sampletones_application/utils/gui/dialogs/windows/confirmation.py @@ -17,23 +17,23 @@ from sampletones_application.ui.elements.status import GUIStatusBar from sampletones_application.utils.gui.align import table_wrapper from sampletones_application.utils.gui.dialog_navigation import FocusStop -from sampletones_application.utils.gui.dpg import dpg_configure_item from sampletones_application.utils.gui.keyboard import KeyRouter from sampletones_application.utils.gui.shortcuts.source import ShortcutSource from sampletones_application.utils.palette.colors.base import BaseColor -from sampletones_shared.types.callback import Callback +from sampletones_shared.types.callback import Callback, VoidCallback CANCEL_FOCUS_STOP: Final[int] = 1 class GUIConfirmationWindow(GUIDialogWindow): - """A modal asking one question, answered through OK, Cancel, or the title-bar close. - - ``on_confirm``/``on_cancel`` run on the respective choice, and the title bar reads as - the negative one, so every way out of the prompt reaches the caller. A checked opt-out - checkbox adds ``on_opt_out`` to a confirmation, letting the caller suppress future - prompts. Cancel — the initially focused button — keeps the prompt answerable by - keyboard alone. + """A modal asking one question, answered through OK, Cancel, Escape, or the title-bar close. + + ``on_confirm``/``on_cancel`` run on the respective choice, and the title bar and Escape + read as the negative one, so every way out of the prompt reaches the caller. Each answer + runs a frame after the prompt has left the screen, so a question the answer asks next + opens alone. A checked opt-out checkbox adds ``on_opt_out`` to a confirmation, letting + the caller suppress future prompts; the box is read as the prompt leaves. Cancel — the + initially focused button — keeps the prompt answerable by keyboard alone. """ def __init__( @@ -101,24 +101,15 @@ def create_window(self) -> None: ok_button_tag = compose_tag(self.tag, SUF_BUTTON_OK) cancel_button_tag = compose_tag(self.tag, SUF_BUTTON_CANCEL) - def disable() -> None: - dpg_configure_item(ok_button_tag, enabled=False) - dpg_configure_item(cancel_button_tag, enabled=False) - def _on_confirm() -> None: - disable() - if self._opt_out_label is not None and self._on_opt_out is not None and dpg.get_value(opt_out_tag): - self._on_opt_out() - - self._on_confirm() - self.hide() + self._leave_then(self._confirmation(opted_out=self._is_opted_out(opt_out_tag))) def _on_cancel() -> None: - disable() - if self._on_cancel is not None: - self._on_cancel() + if self._on_cancel is None: + self.hide() + return - self.hide() + self._leave_then(self._on_cancel) def content(parent: str) -> None: dpg.add_text(self._message, parent=parent, wrap=self._wrap) @@ -170,3 +161,20 @@ def buttons(_: None) -> None: on_escape=_on_cancel, initial_index=CANCEL_FOCUS_STOP, ) + + def _is_opted_out(self, opt_out_tag: str) -> bool: + """Whether the reader ticked the opt-out box, read while the prompt still stands.""" + return self._opt_out_label is not None and dpg.does_item_exist(opt_out_tag) and bool(dpg.get_value(opt_out_tag)) + + def _confirmation(self, *, opted_out: bool) -> VoidCallback: + """The confirmation this appearance answers with, the opt-out riding it when it was ticked.""" + on_confirm = self._on_confirm + on_opt_out = self._on_opt_out if opted_out else None + + def confirm() -> None: + if on_opt_out is not None: + on_opt_out() + + on_confirm() + + return confirm diff --git a/src/sampletones_application/utils/gui/dialogs/windows/save_confirmation.py b/src/sampletones_application/utils/gui/dialogs/windows/save_confirmation.py index 12148424e..794c9023f 100644 --- a/src/sampletones_application/utils/gui/dialogs/windows/save_confirmation.py +++ b/src/sampletones_application/utils/gui/dialogs/windows/save_confirmation.py @@ -13,7 +13,7 @@ from sampletones_application.ui.elements.dialog import GUIDialogWindow from sampletones_application.utils.gui.align import table_wrapper from sampletones_application.utils.gui.dialog_navigation import FocusStop -from sampletones_application.utils.gui.dpg import dpg_configure_item +from sampletones_application.utils.gui.dialogs.outcome import SaveOutcome from sampletones_application.utils.gui.keyboard import KeyRouter from sampletones_application.utils.gui.shortcuts.source import ShortcutSource from sampletones_shared.types.callback import Callback @@ -24,11 +24,12 @@ class GUISaveConfirmationWindow(GUIDialogWindow): """A modal save-or-proceed prompt for an unsaved document. - ``on_save`` writes the document and reports whether it completed; the prompt runs - ``on_confirm`` and closes once the save reports success, so a canceled save keeps the - prompt open for another attempt. The middle button discards the pending changes and - runs ``on_confirm`` to proceed, and Cancel — the initially focused button — dismisses - the prompt. + Every answer runs a frame after the prompt has left the screen, so whatever it opens stands + alone. Save runs ``on_save``, which reports a :class:`SaveOutcome`: a written document runs + ``on_confirm``, a save the reader called off brings the prompt back with the same question, + and a failed save leaves its error on screen by itself. The middle button discards the + pending changes and runs ``on_confirm`` to proceed, and Cancel — the initially focused + button — dismisses the prompt. """ def __init__( @@ -48,7 +49,7 @@ def __init__( self._message: str self._title: str - self._on_save: Callable[[], bool] + self._on_save: Callable[[], SaveOutcome] self._on_confirm: Callback self._ok_label: str @@ -63,7 +64,7 @@ def prepare( # pylint: disable=arguments-differ self, message: str, title: str, - on_save: Callable[[], bool], + on_save: Callable[[], SaveOutcome], on_confirm: Callback, *, ok_label: str, @@ -80,27 +81,11 @@ def create_window(self) -> None: ok_button_tag = compose_tag(self.tag, SUF_BUTTON_OK) cancel_button_tag = compose_tag(self.tag, SUF_BUTTON_CANCEL) - def disable() -> None: - dpg_configure_item(save_button_tag, enabled=False) - dpg_configure_item(ok_button_tag, enabled=False) - dpg_configure_item(cancel_button_tag, enabled=False) - def _on_save() -> None: - if not self._on_save(): - return - - disable() - self._on_confirm() - self.hide() + self._leave_then(self._save_and_go_on) def _on_confirm() -> None: - disable() - self._on_confirm() - self.hide() - - def _on_cancel() -> None: - disable() - self.hide() + self._leave_then(self._on_confirm) def content(parent: str) -> None: dpg.add_text(self._message, parent=parent, wrap=self._wrap) @@ -122,21 +107,41 @@ def buttons(_: None) -> None: GUIButton( tag=cancel_button_tag, label=self._cancel_label, - callback=_on_cancel, + callback=self.hide, width=-1, ) buttons(None) - with self.dialog_window(label=self._title, on_close=_on_cancel): + with self.dialog_window(label=self._title, on_close=self.hide): content(self.tag) self._install_navigation( [ FocusStop.button(save_button_tag, _on_save), FocusStop.button(ok_button_tag, _on_confirm), - FocusStop.button(cancel_button_tag, _on_cancel), + FocusStop.button(cancel_button_tag, self.hide), ], - on_escape=_on_cancel, + on_escape=self.hide, initial_index=CANCEL_FOCUS_STOP, ) + + def _save_and_go_on(self) -> None: + """Writes the document and goes where the outcome leads, once the prompt has left. + + A written document goes on to what the prompt was guarding. A save the reader called off + puts the same question again. A failed save showed its error, which stands alone. + """ + match self._on_save(): + case SaveOutcome.WRITTEN: + self._on_confirm() + case SaveOutcome.CALLED_OFF: + self.show( + self._message, + self._title, + self._on_save, + self._on_confirm, + ok_label=self._ok_label, + ) + case SaveOutcome.FAILED: + pass diff --git a/src/sampletones_application/utils/gui/keyboard/capture.py b/src/sampletones_application/utils/gui/keyboard/capture.py index 804c681f1..10f88a471 100644 --- a/src/sampletones_application/utils/gui/keyboard/capture.py +++ b/src/sampletones_application/utils/gui/keyboard/capture.py @@ -59,7 +59,7 @@ def stop(self) -> None: return self._listening = False - self._router.pop_modal() + self._router.pop_modal(self) def handle_key(self, event: KeyEvent) -> None: """Reads the press, reporting the combination it names once one arrives.""" diff --git a/src/sampletones_application/utils/gui/keyboard/router.py b/src/sampletones_application/utils/gui/keyboard/router.py index fe80f5565..e50bdb516 100644 --- a/src/sampletones_application/utils/gui/keyboard/router.py +++ b/src/sampletones_application/utils/gui/keyboard/router.py @@ -95,10 +95,16 @@ def push_modal(self, handler: ModalKeyHandler) -> None: """ self._modal_stack.append(handler) - def pop_modal(self) -> None: - """Releases the top modal dialog's claim on the keyboard, ignored when none is open.""" - if self._modal_stack: - self._modal_stack.pop() + def pop_modal(self, handler: ModalKeyHandler) -> None: + """Releases the latest claim ``handler`` holds on the keyboard, wherever it stands. + + A dialog can close while one it raised still stands, so a release names whose claim it + gives up and every other claim keeps its place. A handler holding no claim changes nothing. + """ + for index in range(len(self._modal_stack) - 1, -1, -1): + if self._modal_stack[index] is handler: + del self._modal_stack[index] + return def route(self, event: KeyEvent) -> bool: """Offers the event to the active scopes, highest priority first, stopping at the diff --git a/src/sampletones_application/utils/gui/modal_queue.py b/src/sampletones_application/utils/gui/modal_queue.py new file mode 100644 index 000000000..38dac23b2 --- /dev/null +++ b/src/sampletones_application/utils/gui/modal_queue.py @@ -0,0 +1,194 @@ +from collections import deque +from dataclasses import dataclass +from typing import ClassVar, Deque, Dict, List, Optional + +from sampletones_application.utils.gui.frame import FrameCallbackManager +from sampletones_shared.meta import NonInstantiableMeta +from sampletones_shared.types.callback import VoidCallback + + +@dataclass(frozen=True) +class WaitingModal: + """A modal window asked for while the screen belonged to another conversation. + + Attributes: + tag: The window the request builds. + build: What builds it, with everything the request carried. + """ + + tag: str + build: VoidCallback + + +class ModalQueue(metaclass=NonInstantiableMeta): + """The one modal window DearPyGui shows at a time, and the modal windows waiting for the screen. + + DearPyGui shows one modal window at a time. A modal built while another one stands opens hidden, + out of every reader's reach, and runs the answer its title-bar close gives. A modal built in the + frame another one left in meets the same fate, since that frame still draws the one that left. + + The screen therefore belongs to one conversation at a time: a modal window, the modals it hands + the screen to while it steps aside, and the ones its answers raise. A modal asked for from + anywhere else, such as the report of a job that finished or a prompt raised by a gesture that + waited, takes its place in line and opens once the conversation holding the screen has ended, + a frame after its last window left. A window asked for again while it waits keeps its place in + line with the newer request. The line is one for the process, as DearPyGui's context is. + + A hand-off is how a conversation keeps the screen while its windows change: what runs a frame + after a window stepped aside or left, opening the modal that comes next or bringing the window + back. Every call arrives on the render thread, where windows are built and deleted. + """ + + _shown: ClassVar[Optional[str]] = None + _aside: ClassVar[List[str]] = [] + _returning: ClassVar[Dict[str, VoidCallback]] = {} + _hand_offs: ClassVar[List[VoidCallback]] = [] + _waiting: ClassVar[Deque[WaitingModal]] = deque() + _clearing: ClassVar[bool] = False + _handing_off: ClassVar[bool] = False + _turn_due: ClassVar[bool] = False + + @classmethod + def open(cls, tag: str, build: VoidCallback) -> None: + """Builds a modal window now where the screen is free for it, and once it is free otherwise. + + A modal a hand-off raises joins the conversation that handed it the screen, ahead of every + modal waiting in line. Any other one waits for the conversation holding the screen to end. + + Args: + tag: The window ``build`` builds. + build: What builds the window. + """ + cls._forget_waiting(tag) + waiting = WaitingModal(tag=tag, build=build) + if cls._shown is None and not cls._clearing and (cls._handing_off or not cls._screen_taken()): + cls._build(waiting) + elif cls._handing_off: + cls._waiting.appendleft(waiting) + else: + cls._waiting.append(waiting) + + @classmethod + def leave(cls, tag: str) -> None: + """Gives up the screen a deleted window held, standing on it or stepped aside. + + A window still waiting for the screen gives up its place in line instead. + """ + cls._forget_waiting(tag) + cls._returning.pop(tag, None) + if cls._shown == tag: + cls._shown = None + cls._clearing = True + cls._take_a_turn() + elif tag in cls._aside: + cls._aside.remove(tag) + cls._take_a_turn() + + @classmethod + def step_aside(cls, tag: str) -> None: + """Keeps the screen for a window that went off it, so the modal it hands over to opens next.""" + if cls._shown != tag: + return + + cls._shown = None + cls._aside.append(tag) + cls._clearing = True + + @classmethod + def come_back(cls, tag: str, reveal: VoidCallback) -> None: + """Brings a window that stepped aside back onto the screen once the screen is free for it. + + Args: + tag: The window that stepped aside. + reveal: What puts the window back on screen. + """ + if tag not in cls._aside: + return + + cls._returning[tag] = reveal + cls._take_a_turn() + + @classmethod + def hand_off(cls, continuation: VoidCallback) -> None: + """Runs ``continuation`` a frame from now as part of the conversation holding the screen. + + A modal ``continuation`` opens takes the screen ahead of every modal waiting in line. + """ + cls._hand_offs.append(continuation) + cls._take_a_turn() + + @classmethod + def clear(cls) -> None: + """Forgets every window, which is where a DearPyGui context that was just made starts from.""" + cls._shown = None + cls._aside = [] + cls._returning = {} + cls._hand_offs = [] + cls._waiting = deque() + cls._clearing = False + cls._handing_off = False + cls._turn_due = False + + @classmethod + def _screen_taken(cls) -> bool: + """Whether a conversation holds the screen, or a window left it in the frame being drawn.""" + return cls._shown is not None or bool(cls._aside) or bool(cls._hand_offs) or cls._clearing + + @classmethod + def _build(cls, waiting: WaitingModal) -> None: + cls._shown = waiting.tag + waiting.build() + + @classmethod + def _forget_waiting(cls, tag: str) -> None: + cls._waiting = deque(waiting for waiting in cls._waiting if waiting.tag != tag) + + @classmethod + def _take_a_turn(cls) -> None: + if cls._turn_due: + return + + cls._turn_due = True + FrameCallbackManager.set_frame_callback(cls._turn) + + @classmethod + def _turn(cls) -> None: + """Carries the screen a frame further: the hand-offs, then a window coming back, then the line. + + A window leaving during the turn keeps what comes after it for the next frame, since the + frame being drawn still carries the window that left. + """ + cls._turn_due = False + cls._clearing = False + cls._run_hand_offs() + if cls._clearing or cls._shown is not None: + return + + if cls._bring_back(): + return + + if not cls._screen_taken() and cls._waiting: + cls._build(cls._waiting.popleft()) + + @classmethod + def _run_hand_offs(cls) -> None: + hand_offs, cls._hand_offs = cls._hand_offs, [] + cls._handing_off = True + try: + for continuation in hand_offs: + continuation() + finally: + cls._handing_off = False + + @classmethod + def _bring_back(cls) -> bool: + """Puts the latest window that stepped aside and asked to come back on the screen.""" + for tag in reversed(cls._aside): + reveal = cls._returning.pop(tag, None) + if reveal is not None: + cls._aside.remove(tag) + cls._shown = tag + reveal() + return True + + return False diff --git a/src/sampletones_application/view_model/reconstruction/update.py b/src/sampletones_application/view_model/reconstruction/update.py deleted file mode 100644 index 867c0fe63..000000000 --- a/src/sampletones_application/view_model/reconstruction/update.py +++ /dev/null @@ -1,10 +0,0 @@ -from typing import NamedTuple - -from sampletones_core.constants.enums import ChannelName, FeatureKey -from sampletones_core.exporters import Features - - -class ReconstructionUpdate(NamedTuple): - channel_name: ChannelName - feature_key: FeatureKey - features: Features diff --git a/src/sampletones_application/view_model/shared/stems.py b/src/sampletones_application/view_model/shared/stems.py index 5f134f495..f58c35d22 100644 --- a/src/sampletones_application/view_model/shared/stems.py +++ b/src/sampletones_application/view_model/shared/stems.py @@ -166,6 +166,11 @@ def empty(cls) -> Self: def row_count(self) -> int: return len(self.rows) + @property + def releasable_count(self) -> int: + """How many rows name something a removal takes out, which leaves the reader's own frames out of the count.""" + return sum(1 for row in self.rows if row.releasable) + @property def holds_folders(self) -> bool: """A folder stands among the rows, which is what gives the list a disclosure column.""" diff --git a/src/sampletones_config/behavior/general.yaml b/src/sampletones_config/behavior/general.yaml index 1d4e14a92..3cc15aea3 100644 --- a/src/sampletones_config/behavior/general.yaml +++ b/src/sampletones_config/behavior/general.yaml @@ -1,7 +1,6 @@ scheduling: delays: schedule: 12 - reconstruction_update: 1 cancel: 30 priorities: update_status: 0 diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 95605fd25..a5d0c7f1f 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -30,6 +30,7 @@ global.dialog.title.reconstruct_directory: "Reconstruct directory" global.dialog.title.exit_confirmation: "Exit" global.dialog.title.save_reconstruction: "Save reconstruction" global.dialog.title.reconstruction_saved: "Reconstruction saved" +global.dialog.title.edit_voice_unsaved_reconstruction: "Edit voice" global.dialog.title.close_unsaved_reconstruction: "Close reconstruction" global.dialog.title.load_unsaved_reconstruction: "Load reconstruction" global.dialog.title.save_project: "Save project" @@ -70,9 +71,11 @@ global.dialog.message.reconstruction_save_failed: "Failed to save reconstruction global.dialog.message.config_save_failed: "Failed to save configuration." global.dialog.message.exit_conversion_in_progress: "A conversion is currently in progress. Are you sure you want to exit?" global.dialog.message.exit_library_generation_in_progress: "Library generation is currently in progress. Are you sure you want to exit?" -global.dialog.message.exit_unsaved_reconstruction: "The current reconstruction has unsaved changes. Are you sure you want to exit?" +global.dialog.message.exit_unsaved_reconstruction: "The current reconstruction has unsaved changes. Do you want to save it before exiting?" +global.dialog.message.edit_voice_unsaved_reconstruction: "The current reconstruction has unsaved changes. Do you want to save it before you edit this voice?" global.dialog.message.close_unsaved_reconstruction: "The current reconstruction has unsaved changes. Are you sure you want to close it?" global.dialog.message.load_unsaved_reconstruction: "The current reconstruction has unsaved changes. Do you want to save it before loading a new one?" +global.dialog.message.load_replaced_reconstruction: "The conversion wrote over the reconstruction you have open. Loading it throws away your unsaved changes. To keep them, click Cancel. Then choose Save reconstruction as... in the Reconstruction menu and pick another file." global.dialog.message.project_saved_successfully: "Project saved successfully." global.dialog.message.project_save_failed: "Failed to save project." global.dialog.message.project_exported_successfully: "FamiTracker module exported successfully." @@ -81,10 +84,11 @@ global.dialog.message.bitphase_project_exported_successfully: "Bitphase project global.dialog.message.bitphase_project_export_failed: "Failed to export Bitphase project." global.dialog.message.nsf_project_exported_successfully: "NSF program exported successfully." global.dialog.message.nsf_project_export_failed: "Failed to export NSF program." -global.dialog.message.export_skipped_rows: "Rows whose voice has no instrument on that row's channel were written as note cuts instead:" +global.dialog.message.export_skipped_rows: "These rows play a voice on a channel it doesn't use. The file silences the channel there instead:" +global.dialog.message.export_untransposed_rows: "These rows change the pitch of a playing note. The file can't make that change there, so the note keeps its old pitch:" global.dialog.template.export_skipped_row: "Frame {frame}, {channel}, row {row}: {voice}" global.dialog.template.export_skipped_rows_more: "and {count} more" -global.dialog.template.export_truncated: "Shortened instruments: {instruments}. Envelopes longer than this format allows were cut to {frames} frames." +global.dialog.template.export_truncated: "Instruments cut short: {instruments}. The file keeps the first {frames} ticks of each one." global.dialog.message.new_unsaved_project: "The current project has unsaved changes. Do you want to save it before starting a new one?" global.dialog.message.open_unsaved_project: "The current project has unsaved changes. Do you want to save it before opening another?" global.dialog.message.close_unsaved_project: "The current project has unsaved changes. Do you want to save it before closing?" @@ -540,10 +544,10 @@ reconstructions.instruments.message.status_input_period: "Ctrl + click to type v reconstructions.instruments.message.status_audition: "Click to choose the generator the note keys sound this instrument on." reconstructions.instruments.message.status_bar: "Click to change {instrument_feature}. Scroll to zoom horizontally. Alt + scroll to pan. Right-click for more options." reconstructions.instruments.message.status_sequence: "Type whole numbers to change {instrument_feature}, and \"|\" before the item it repeats from. Press Enter to apply." -reconstructions.instruments.message.status_sequence_too_long: "{instrument_feature}: {items} items. {kept}." -reconstructions.instruments.template.kept_famitracker: "FamiTracker keeps {limit}" -reconstructions.instruments.template.kept_bitphase: "a Bitphase project keeps {limit}" -reconstructions.instruments.template.kept_bitphase_preset: "a Bitphase preset keeps {limit}" +reconstructions.instruments.message.status_sequence_too_long: "{instrument_feature} has {items} items. {kept}." +reconstructions.instruments.template.kept_famitracker: "FamiTracker keeps the first {limit}" +reconstructions.instruments.template.kept_bitphase: "a Bitphase project keeps the first {limit}" +reconstructions.instruments.template.kept_bitphase_preset: "a Bitphase preset keeps the first {limit}" reconstructions.instruments.template.kept_separator: ", " reconstructions.instruments.message.status_copy_sequence: "Copy sequence to clipboard." reconstructions.instruments.message.status_channel_toggle: "Click to turn {on_or_off} {channel_name}." @@ -551,8 +555,8 @@ reconstructions.instruments.message.status_channel_not_available: "{channel_name reconstructions.instruments.message.status_export_instrument: "Writes the {channel} channel's instrument, in the format the chosen file extension selects." reconstructions.instruments.message.export_instrument_success: "Instrument saved successfully." reconstructions.instruments.message.export_instruments_success: "Reconstruction instruments saved successfully." -reconstructions.instruments.message.export_instrument_truncated: "The envelope was truncated from {source_frames} to {frames} frames." -reconstructions.instruments.message.export_instruments_truncated: "{instruments} envelopes were truncated to {frames} frames." +reconstructions.instruments.message.export_instrument_truncated: "This instrument is {source_frames} ticks long. The file keeps the first {frames}." +reconstructions.instruments.message.export_instruments_truncated: "Instruments cut short: {instruments}. The file keeps the first {frames} ticks of each one." reconstructions.instruments.message.export_instrument_failed: "Failed to export instrument." reconstructions.instruments.message.export_instruments_failed: "Failed to export reconstruction instruments." reconstructions.instruments.title.export_status_dialog: "Export status" diff --git a/src/sampletones_core/exporters/feature.py b/src/sampletones_core/exporters/feature.py index 9016955fa..6d0e32e08 100644 --- a/src/sampletones_core/exporters/feature.py +++ b/src/sampletones_core/exporters/feature.py @@ -17,7 +17,7 @@ class Features(BaseModel): cycle — carrying the values it writes per tick together with the item they repeat from, beside the ``initial_pitch`` the arpeggio is measured against. A dimension the generator offers is an envelope, ``None`` for one it lacks; an envelope of no items marks a dimension the instrument - leaves to the channel, which keeps the value it holds. + leaves to the channel, which sounds it at the value every note starts on. Attributes: initial_pitch: Reference pitch the arpeggio envelope is measured against. @@ -146,8 +146,8 @@ def held_features(self) -> Tuple[FeatureKey, ...]: """The dimensions the channel governs, whose envelopes carry no item. An instrument writes the dimensions it describes and leaves the rest to the channel, - which keeps the value it already holds for as long as the instrument sounds. These - are the dimensions it leaves, listed in the order the model declares them. + which sounds them at the value every note starts on for as long as the instrument sounds. + These are the dimensions it leaves, listed in the order the model declares them. """ return tuple(feature_key for feature_key, envelope in self.envelopes.items() if not envelope.written) diff --git a/src/sampletones_core/exporters/rows/__init__.py b/src/sampletones_core/exporters/rows/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_core/exporters/rows/levels.py b/src/sampletones_core/exporters/rows/levels.py new file mode 100644 index 000000000..c787c9e8d --- /dev/null +++ b/src/sampletones_core/exporters/rows/levels.py @@ -0,0 +1,172 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import FrozenSet, Optional, Set + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.general import MAX_VOLUME, SILENT_VOLUME +from sampletones_core.performance.modifiers import triangle_sounds_at +from sampletones_core.project.patterns.row import Row +from sampletones_core.project.song import Song +from sampletones_core.project.voices.note_on import NoteOn + + +@dataclass(frozen=True) +class RowPlace: + """Where a row stands in the song, both as the order plays it and as the pattern pool holds it. + + Attributes: + order_position: The frame of the order the row is played in. + pattern_index: The pattern the frame plays on the row's channel. + row_index: The row's position within that pattern. + """ + + order_position: int + pattern_index: int + row_index: int + + +@dataclass(frozen=True) +class LevelWalk: + """One pass of a channel through the song, following the level a tracker's volume column carries. + + Attributes: + full_level_notes: The notes stating no level that the pass reaches while the channel carries + a level other than full. + closing_volume: The level the channel carries once the pass reaches the end of the order. + """ + + full_level_notes: FrozenSet[RowPlace] + closing_volume: int + + @classmethod + def walk( + cls, + song: Song, + channel_name: ChannelName, + opening_volume: int, + ) -> LevelWalk: + """Walks one channel through the order from ``opening_volume``, frame by frame and row by row. + + A tracker's volume column keeps the last level a cell wrote, so a row that states a level + sets it, and every other row leaves it where it stands. A note whose row states none starts + at the full level in the song, and the export writes it there wherever the tracker carries + another, so the channel stands at the full level after it either way. A note naming a voice + with no instrument on the channel counts alike: the song sets the level there too, and the + note cut the export writes in its place carries it. + + Args: + song: The arrangement being exported. + channel_name: The channel whose rows are walked. + opening_volume: The level the channel carries into the first frame. + + Returns: + LevelWalk: The notes reached below or above the full level, and the level the pass ends at. + """ + volume = opening_volume + notes: Set[RowPlace] = set() + for order_position, frame in enumerate(song.order): + pattern_index = frame.get(channel_name) + if pattern_index is None: + continue + + pattern = song.pattern(channel_name, pattern_index) + if pattern is None: + continue + + for row_index, row in enumerate(pattern.rows[: song.rows_per_pattern]): + if cls._starts_at_full_level(row) and volume != MAX_VOLUME: + notes.add( + RowPlace( + order_position=order_position, + pattern_index=pattern_index, + row_index=row_index, + ) + ) + + volume = cls._volume_after(row, volume) + + return cls(full_level_notes=frozenset(notes), closing_volume=volume) + + @staticmethod + def _starts_at_full_level(row: Row) -> bool: + """Whether a row starts a note at the full level, which is a note-on stating no level of its own.""" + match row.command: + case NoteOn(): + return row.volume is None + case _: + return False + + @staticmethod + def _volume_after(row: Row, volume: int) -> int: + """The level a tracker carries past one row: the level its cell writes, or the one it carried.""" + if row.volume is not None: + return row.volume + + match row.command: + case NoteOn(): + return MAX_VOLUME + case _: + return volume + + +def full_level_notes(song: Song, channel_name: ChannelName) -> FrozenSet[RowPlace]: + """The notes of one channel whose cell writes the full level, so a tracker starts them where the song does. + + The song starts a note at the full level wherever its row states none, while a tracker's volume + column carries the last level a cell wrote into every note after it. A note whose row states no + level is therefore written at the full level wherever the tracker reaches it carrying another, + which is where a user writing the song in the tracker would type one. A note the tracker + reaches at the full level keeps its blank cell, so the document states a level only where the + level changes. + + A tracker plays the order frame by frame and returns to the first frame at its end, keeping the + level each channel was left at. The walk therefore passes through the song twice: from the full + level a channel starts at, and from the level the song ends on. A pattern the order plays in + several frames is reached in each of them. + + Args: + song: The arrangement being exported. + channel_name: The channel whose notes are found. + + Returns: + FrozenSet[RowPlace]: The notes whose cell writes the full level. + """ + opening = LevelWalk.walk(song, channel_name, MAX_VOLUME) + looping = LevelWalk.walk(song, channel_name, opening.closing_volume) + return opening.full_level_notes | looping.full_level_notes + + +def cell_volume( + row: Row, + channel_name: ChannelName, + *, + full_level: bool, +) -> Optional[int]: + """The level a row's volume cell states, in the song's own terms. + + A row states its own level, a note :func:`full_level_notes` found states the full level, and any + other row states none, so the tracker carries the level on. The triangle reads a level as + whether it sounds: the song sounds it above half volume only, while both trackers sound it at + any level above silence. A triangle row at a level the song rests at therefore states silence, + and a row at a level it sounds at states that level. + + Args: + row: The row being written. + channel_name: The channel the row stands on. + full_level: Whether the row is a note that writes the full level. + + Returns: + Optional[int]: The level the cell states, or ``None`` where the cell stays empty. + """ + volume = row.volume + if volume is None and full_level: + volume = MAX_VOLUME + + if volume is None: + return None + + if channel_name == ChannelName.TRIANGLE and not triangle_sounds_at(volume): + return SILENT_VOLUME + + return volume diff --git a/src/sampletones_core/exporters/rows/pitch.py b/src/sampletones_core/exporters/rows/pitch.py new file mode 100644 index 000000000..48d278d22 --- /dev/null +++ b/src/sampletones_core/exporters/rows/pitch.py @@ -0,0 +1,40 @@ +from typing import Final, Iterable + +from sampletones_core.constants.general import MAX_PITCH, MIN_PITCH + +FLAT_CONTOUR_STEP: Final[int] = 0 + + +def highest_step(steps: Iterable[int]) -> int: + """The highest semitone step a contour moves its note by, which is none for a contour of no steps. + + Args: + steps: The contour's steps, one per tick. + + Returns: + int: The highest of them. + """ + return max(steps, default=FLAT_CONTOUR_STEP) + + +def written_pitch(pitch: int, contour_top: int) -> int: + """The pitch a tracker cell writes for a transposed voice, so its contour sounds where the song's does. + + The song holds every tick's transposed pitch within the range the channels play. A tracker moves + the written note by the contour's step each tick and holds the result at the same top, so a + pitch up to the top is written as it is, and one above it is written at the top. + + Below the range a tracker plays every note at its longest period, a little flat of the lowest + pitch, where the song holds such a tick. A pitch below the range is therefore raised only as far + as bringing the contour's highest step up to the lowest pitch. Each tick the song plays within + the range keeps its own note, and a contour lying wholly below the range sounds its highest step + at the lowest pitch, which for a flat contour is every tick. + + Args: + pitch: The voice's reference pitch with the row's transpose added. + contour_top: The highest semitone step the voice's contour moves the note by. + + Returns: + int: The pitch the cell writes. + """ + return min(MAX_PITCH, max(pitch, MIN_PITCH - contour_top)) diff --git a/src/sampletones_core/exporters/rows/transpose.py b/src/sampletones_core/exporters/rows/transpose.py new file mode 100644 index 000000000..8eec710f5 --- /dev/null +++ b/src/sampletones_core/exporters/rows/transpose.py @@ -0,0 +1,160 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Container, Final, List, Optional, Tuple + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.exporters.rows.levels import RowPlace +from sampletones_core.project.patterns.row import Row +from sampletones_core.project.song import Song +from sampletones_core.project.voices.note_off import NoteOff +from sampletones_core.project.voices.note_on import NoteOn + +NOTE_TRANSPOSE: Final[int] = 0 +NO_ROWS: Final[int] = 0 + + +@dataclass(frozen=True) +class Repitch: + """A row stating a transpose and no note, which moves the note sounding on its channel. + + The song keeps the voice where it stands, so the note goes on from the tick it reached, and every + tick from this row on sounds at the transpose the row sets. + + Attributes: + place: Where the row stands in the song. + transpose: The transpose the row sets, measured from the voice's reference like a note-on's. + rows: How many rows the note has sounded for when this row begins, counted across frames. + """ + + place: RowPlace + transpose: int + rows: int + + +@dataclass(frozen=True) +class SoundingNote: + """A note-on the song sounds, with the transpose rows that move its pitch while it sounds. + + Attributes: + place: Where the note-on stands in the song. + voice_id: The voice the note sounds. + transpose: The transpose the note starts at, which is zero where its row states none. + repitches: The transpose rows that reach the note, in the order the song plays them. + """ + + place: RowPlace + voice_id: str + transpose: int + repitches: Tuple[Repitch, ...] + + +@dataclass +class _OpenNote: + """The note a walk is following, gathering the rows that reach it until another row ends it.""" + + place: RowPlace + voice_id: str + transpose: int + rows: int = field(default=NO_ROWS) + repitches: List[Repitch] = field(default_factory=list) + + def closed(self) -> SoundingNote: + return SoundingNote( + place=self.place, + voice_id=self.voice_id, + transpose=self.transpose, + repitches=tuple(self.repitches), + ) + + +@dataclass +class _NoteWalk: + """One channel's pass through the song, following the note it sounds from row to row.""" + + channel_name: ChannelName + instruments: Container[Tuple[str, ChannelName]] + notes: List[SoundingNote] = field(default_factory=list) + sounding: Optional[_OpenNote] = field(default=None) + + def read(self, row: Row, place: RowPlace) -> None: + """Moves the walk onto one row, the way the song's own walk applies it.""" + match row.command: + case NoteOn() as note_on: + self._close() + if (note_on.voice_id, self.channel_name) in self.instruments: + self.sounding = _OpenNote( + place=place, + voice_id=note_on.voice_id, + transpose=row.transpose if row.transpose is not None else NOTE_TRANSPOSE, + ) + case NoteOff(): + self._close() + case None: + if row.transpose is not None and self.sounding is not None: + self.sounding.repitches.append( + Repitch( + place=place, + transpose=row.transpose, + rows=self.sounding.rows, + ) + ) + + def pass_row(self) -> None: + """Counts one more row the sounding note has played through.""" + if self.sounding is not None: + self.sounding.rows += 1 + + def finish(self) -> Tuple[SoundingNote, ...]: + """The notes the pass found a transpose row reaching.""" + self._close() + return tuple(self.notes) + + def _close(self) -> None: + if self.sounding is not None and self.sounding.repitches: + self.notes.append(self.sounding.closed()) + + self.sounding = None + + +def sounding_notes( + song: Song, + channel_name: ChannelName, + instruments: Container[Tuple[str, ChannelName]], +) -> Tuple[SoundingNote, ...]: + """Walks one channel through the order once, gathering the notes that transpose rows move. + + The song's walk plays the order frame by frame, and a row naming no note leaves the voice + playing, so a row stating a transpose moves the note already sounding. A note-on or a note-off + ends the note, and so does a note-on naming a voice with no instrument on the channel, which the + export writes as a note cut. A frame leaving the channel empty plays on with the + note it carries, so a note's rows are counted across frames. A transpose row reached while no + note sounds moves nothing. + + Args: + song: The arrangement being exported. + channel_name: The channel whose rows are walked. + instruments: The ``(voice id, channel)`` pairs the export holds an instrument for. + + Returns: + Tuple[SoundingNote, ...]: The notes at least one transpose row reaches, in the order the + song plays them. + """ + walk = _NoteWalk(channel_name=channel_name, instruments=instruments) + for order_position, frame in enumerate(song.order): + pattern_index = frame.get(channel_name) + pattern = song.pattern(channel_name, pattern_index) if pattern_index is not None else None + for row_index in range(song.rows_per_pattern): + if pattern_index is not None and pattern is not None and row_index < len(pattern.rows): + walk.read( + pattern.rows[row_index], + RowPlace( + order_position=order_position, + pattern_index=pattern_index, + row_index=row_index, + ), + ) + + walk.pass_row() + + return walk.finish() diff --git a/src/sampletones_core/exporters/skipped.py b/src/sampletones_core/exporters/skipped.py index 3fad9a385..b2178457a 100644 --- a/src/sampletones_core/exporters/skipped.py +++ b/src/sampletones_core/exporters/skipped.py @@ -1,5 +1,6 @@ from dataclasses import dataclass -from typing import Container, Final, Generic, List, Optional, Tuple, TypeVar +from enum import StrEnum +from typing import Container, Final, Generic, Iterable, List, Optional, Tuple, TypeVar from sampletones_core.constants.enums import ChannelName from sampletones_core.exporters.truncation import EnvelopeTruncation @@ -9,24 +10,40 @@ DocumentT = TypeVar("DocumentT") +class SkipReason(StrEnum): + """Why an export wrote a row other than the song plays it. + + ``NO_INSTRUMENT`` is a note-on naming a voice with no instrument on its channel, written as a note + cut. ``UNREACHED_TRANSPOSE`` is a transpose row moving a sounding note further than the format's + pitch change reaches, or a row a pattern shared by several frames needs a different change in, + written without the change there. + """ + + NO_INSTRUMENT = "no_instrument" + UNREACHED_TRANSPOSE = "unreached_transpose" + + @dataclass(frozen=True) class SkippedRow: - """A note-on the target format has no instrument for, which the export wrote as a note cut. + """A row the export wrote other than the song plays it, named by where the reader finds it in the tracker. - A voice sounds on the channels its instruments cover, so a row naming it elsewhere plays - nothing in the song either. The row is named by where the reader finds it in the tracker. + A voice sounds on the channels its instruments cover, so a note-on naming it elsewhere plays + nothing in the song either, and the export writes a note cut. A transpose row the format has no + pitch change for keeps the note at the pitch it had. Attributes: - voice_id: The voice the row names. + voice_id: The voice the row names, or the voice it moves. channel: The channel the row stands on. order_position: The frame of the order the row stands in. row_index: The row's position within its pattern. + reason: What the export wrote in the row's place. """ voice_id: str channel: ChannelName order_position: int row_index: int + reason: SkipReason NO_SKIPPED_ROWS: Final[Tuple[SkippedRow, ...]] = () @@ -81,7 +98,26 @@ def find_skipped_rows( channel=channel, order_position=position, row_index=row_index, + reason=SkipReason.NO_INSTRUMENT, ) ) return tuple(skipped) + + +def in_song_order(rows: Iterable[SkippedRow]) -> Tuple[SkippedRow, ...]: + """Rows in the order the song plays them: frame by frame, channel by channel, then row by row. + + Args: + rows: The rows an export reports, gathered in any order. + + Returns: + Tuple[SkippedRow, ...]: The same rows, in the order the song reaches them. + """ + channels = ChannelName.items() + return tuple( + sorted( + rows, + key=lambda skipped: (skipped.order_position, channels.index(skipped.channel), skipped.row_index), + ) + ) diff --git a/src/sampletones_core/exports/implementation/famitracker.py b/src/sampletones_core/exports/implementation/famitracker.py index 220a1c8bc..8c6865c03 100644 --- a/src/sampletones_core/exports/implementation/famitracker.py +++ b/src/sampletones_core/exports/implementation/famitracker.py @@ -59,6 +59,7 @@ def write_instrument( STANDALONE_INSTRUMENT_INDEX, request.name, request.features, + repitched=False, ) write_fti(destination, instrument) announce(report, ExportStage.WRITING, ONE_FILE, ONE_FILE) diff --git a/src/sampletones_core/formats/bitphase/builder.py b/src/sampletones_core/formats/bitphase/builder.py index c3b6ff4be..21757b468 100644 --- a/src/sampletones_core/formats/bitphase/builder.py +++ b/src/sampletones_core/formats/bitphase/builder.py @@ -1,9 +1,9 @@ import math -from dataclasses import dataclass -from typing import Dict, List, Optional, Sequence, Tuple +from typing import FrozenSet, List, Mapping, Optional, Sequence, Tuple from sampletones_core.constants.enums import ChannelName from sampletones_core.constants.general import SILENT_VOLUME +from sampletones_core.exporters.rows.levels import RowPlace, cell_volume, full_level_notes from sampletones_core.exporters.skipped import BuiltDocument, find_skipped_rows from sampletones_core.exporters.slices import iterate_voice_slices from sampletones_core.exporters.truncation import EnvelopeTruncation @@ -24,11 +24,7 @@ from sampletones_core.formats.bitphase.model.project import BitphaseProject from sampletones_core.formats.bitphase.model.song import BitphaseSong from sampletones_core.formats.bitphase.model.table import BitphaseTable -from sampletones_core.formats.bitphase.notes import ( - noise_period_to_note_index, - note_index_to_note_cell, - pitch_to_note_index, -) +from sampletones_core.formats.bitphase.notes import note_index_to_note_cell from sampletones_core.formats.bitphase.specification.channels import ( CHANNEL_LABELS, CHANNEL_TO_INDEX, @@ -61,8 +57,10 @@ VOLUME_OFF, NoteName, ) +from sampletones_core.formats.bitphase.transposes import TransposeCell, TransposePlan from sampletones_core.formats.bitphase.truncation import document_truncation from sampletones_core.formats.bitphase.tuning import concert_frequency, generate_tuning_table +from sampletones_core.formats.bitphase.voices import SliceVoice, SliceVoiceTable from sampletones_core.project.patterns.row import Row from sampletones_core.project.project import Project from sampletones_core.project.tuning import tuning_from_project @@ -72,6 +70,7 @@ from sampletones_shared.constants.project import DEFAULT_ROWS_PER_PATTERN, DEFAULT_SPEED PREVIEW_SPEED = DEFAULT_SPEED +PREVIEW_TRANSPOSE = 0 PREVIEW_TRIGGER_ROW = 0 PREVIEW_REST_PATTERN_ID = FIRST_PATTERN_ID + 1 NO_AUTHOR = "" @@ -82,30 +81,6 @@ GROOVE_TABLE_COUNT = 1 -@dataclass(frozen=True) -class SliceVoice: - """One built instrument together with the table and the note that triggers it. - - Attributes: - number: Value a pattern's instrument column carries to play the instrument. - instrument: The macros the channel reads a value per tick from. - table: The per-tick semitone contour that moves the note. - channel: The NES channel the slice was reconstructed for. - initial_pitch: Pitch the slice's contour is measured against. - ticks: How many ticks the instrument runs before every dimension stands at its end. - """ - - number: int - instrument: BitphaseInstrument - table: BitphaseTable - channel: ChannelName - initial_pitch: int - ticks: int - - -SliceVoiceTable = Dict[Tuple[str, ChannelName], SliceVoice] - - def _build_slice_voice( index: int, name: str, @@ -152,16 +127,9 @@ def _build_slice_voice( ) -def _note_cell(channel_generator: ChannelName, pitch: int) -> NoteCell: - """Resolves a pitch to the note column of the channel the row sits on. - - The noise channel reads its note as a period selector, so its pitch takes the - mapping that reproduces that period; every other channel reads the tuning table. - """ - if channel_generator == ChannelName.NOISE: - return note_index_to_note_cell(noise_period_to_note_index(pitch)) - - return note_index_to_note_cell(pitch_to_note_index(pitch)) +def _note_cell(voice: SliceVoice, transpose: int) -> NoteCell: + """Resolves a slice moved by a row's transpose to the note column that triggers it.""" + return note_index_to_note_cell(voice.note_index(transpose)) def _trigger_row(voice: SliceVoice, note: NoteCell, volume: int) -> BitphaseRow: @@ -245,7 +213,7 @@ def _preview_patterns( channel_rows = _empty_channels(length) for voice in voices: channel = CHANNEL_TO_INDEX[voice.channel] - note = _note_cell(voice.channel, voice.initial_pitch) + note = _note_cell(voice, PREVIEW_TRANSPOSE) channel_rows[channel][PREVIEW_TRIGGER_ROW] = _trigger_row( voice, note, @@ -407,13 +375,19 @@ def _row_cell( row: Row, channel_generator: ChannelName, voices: SliceVoiceTable, + transpose: Optional[TransposeCell], + *, + full_level: bool, ) -> BitphaseRow: """Converts one tracker line to the Bitphase row that plays it. A note-on naming a voice with no instrument on this channel plays nothing in the song, so it - becomes the note cut that silences the channel. + becomes the note cut that silences the channel. The volume column states what + :func:`cell_volume` gives the row, which is the full level on a note Bitphase would otherwise + start at the level the channel carries. A transpose row moving the note sounding writes the + table and the effect its ``transpose`` cell names — see :class:`TransposePlan`. """ - volume = _volume_column(row.volume) + volume = _volume_column(cell_volume(row, channel_generator, full_level=full_level)) cell = BitphaseRow(volume=volume) note_cut = BitphaseRow( note=NoteCell(name=int(NoteName.OFF)), @@ -428,12 +402,17 @@ def _row_cell( if voice is None: cell = note_cut else: - pitch = voice.initial_pitch + (row.transpose or 0) cell = _trigger_row( voice, - _note_cell(channel_generator, pitch), + _note_cell(voice, row.transpose or 0), volume, ) + case None if transpose is not None: + cell = BitphaseRow( + table=transpose.table, + effects=transpose.effects, + volume=volume, + ) case None: pass @@ -445,12 +424,29 @@ def _channel_rows( length: int, channel: ChannelName, voices: SliceVoiceTable, + full_rows: FrozenSet[int], + transposes: Mapping[int, TransposeCell], ) -> List[BitphaseRow]: - cells = [_row_cell(row, channel, voices) for row in rows[:length]] + """Converts one channel's pattern within a frame, writing the full level and the transposes on the rows named.""" + cells = [ + _row_cell( + row, + channel, + voices, + transposes.get(row_index), + full_level=row_index in full_rows, + ) + for row_index, row in enumerate(rows[:length]) + ] cells.extend(BitphaseRow() for _ in range(length - len(cells))) return cells +def _frame_rows(places: FrozenSet[RowPlace], position: int) -> FrozenSet[int]: + """The rows among ``places`` that the order frame at ``position`` plays.""" + 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. @@ -522,30 +518,44 @@ def _groove_channel_rows(length: int, table_id: int) -> List[BitphaseRow]: def _document_tables( voices: Sequence[SliceVoice], groove_table: Optional[BitphaseTable], + transposes: TransposePlan, ) -> Tuple[BitphaseTable, ...]: - """Gathers the tables a document holds: one per slice, and the groove where it takes one.""" + """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 + + +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 tables + return len(voices) + MIN_TABLE_ID - return tables + (groove_table,) + return groove_table.id + GROOVE_TABLE_COUNT def _project_patterns( project: Project, voices: SliceVoiceTable, groove_table: Optional[BitphaseTable], + 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 groove table it is given, so the tempo holds wherever the order jumps. 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. """ song = project.song length = song.rows_per_pattern patterns: List[BitphasePattern] = [] + full_levels = {channel_name: full_level_notes(song, channel_name) for channel_name in ChannelName.items()} for position, frame in enumerate(song.order): channel_rows = _empty_channels(length) @@ -570,6 +580,8 @@ def _project_patterns( length, channel_name, voices, + _frame_rows(full_levels[channel_name], position), + transposes.frame_cells(channel_name, position), ) patterns.append(_to_pattern(position, length, channel_rows)) @@ -591,7 +603,8 @@ def build_bitphase(project: Project) -> BuiltDocument[BitphaseProject]: 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 beside it. A dimension longer than a macro holds keeps its opening values, and the slices - shortened that way are reported beside the rows. + shortened that way are reported beside the rows. A row moving the transpose of a note already + sounding switches the channel to a moved copy of the note's table — see :class:`TransposePlan`. Args: project: The project to write. @@ -601,9 +614,9 @@ def build_bitphase(project: Project) -> BuiltDocument[BitphaseProject]: slices shortened. Raises: - ValueError: If the project holds more than Bitphase has room for, if its samples were - reconstructed at different tunings, or if their concert pitch lies outside the range - a Bitphase song takes. + ValueError: If the project holds more than Bitphase has room for, its transpose rows' + tables included, if its samples were reconstructed at different tunings, or if their + concert pitch lies outside the range a Bitphase song takes. """ a4_tuning = concert_frequency(tuning_from_project(project)) tuning_table = generate_tuning_table(DEFAULT_CPU_FREQUENCY, a4_tuning=a4_tuning) @@ -621,7 +634,13 @@ def build_bitphase(project: Project) -> BuiltDocument[BitphaseProject]: len(voices) + MIN_TABLE_ID, ) ) - patterns = _project_patterns(project, by_reference, groove_table) + transposes = TransposePlan.build( + project.song, + by_reference, + groove, + first_table_id=_moved_table_id(voices, groove_table), + ) + patterns = _project_patterns(project, by_reference, groove_table, transposes) settings = project.settings info = project.info @@ -640,7 +659,7 @@ def build_bitphase(project: Project) -> BuiltDocument[BitphaseProject]: ), ), pattern_order=tuple(pattern.id for pattern in patterns), - tables=_document_tables(voices, groove_table), + tables=_document_tables(voices, groove_table, 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/model/table.py b/src/sampletones_core/formats/bitphase/model/table.py index 1be0778f3..811809e6b 100644 --- a/src/sampletones_core/formats/bitphase/model/table.py +++ b/src/sampletones_core/formats/bitphase/model/table.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import Tuple from pydantic import BaseModel, Field @@ -5,6 +7,7 @@ from sampletones_core.formats.bitphase.model.config import BITPHASE_MODEL_CONFIG from sampletones_core.formats.bitphase.specification.instruments import ( ABSOLUTE_TABLE, + FIRST_TABLE_STEP, LOOP_FROM_START, MAX_TABLE_ID, MIN_TABLE_ID, @@ -47,3 +50,70 @@ class BitphaseTable(BaseModel): default=ABSOLUTE_TABLE, description="Whether each step adds to the value the table has reached, rather than to the note.", ) + + @property + def circling_step(self) -> int: + """The step playback returns to past the last one, which is the first where ``loop`` stands outside the steps. + + Read from ``processTables`` of Bitphase's ``tracker-pattern-processor.js``, which takes ``loop`` + only where it stands above the first step and below the length. + """ + if FIRST_TABLE_STEP < self.loop < len(self.rows): + return self.loop + + return FIRST_TABLE_STEP + + def position_at(self, tick: int) -> int: + """The step playback stands at ``tick`` ticks after the table started, one step per tick. + + Args: + tick: Ticks since the table started, at least zero. + + Returns: + int: The step playback reads on that tick. + """ + length = len(self.rows) + if tick < length: + return tick + + start = self.circling_step + return start + (tick - length) % (length - start) + + def moved( + self, + *, + table_id: int, + shift: int, + start: int, + name: str, + ) -> BitphaseTable: + """This table with every step moved by ``shift``, playing from the step ``start`` on. + + A table starting at a later step lays the steps from there first, then circles over the + steps this one circles over, so it plays what this one plays from ``start`` on. + + Args: + table_id: The identifier the new table takes. + shift: The semitones every step moves by. + start: The step of this table the new one opens on. + name: The name the new table is listed by. + + Returns: + BitphaseTable: The moved table. + """ + rows = tuple(step + shift for step in self.rows) + circling = self.circling_step + if start >= circling: + laid = rows[start:] + rows[circling:start] + loop = LOOP_FROM_START + else: + laid = rows[start:] + loop = circling - start + + return BitphaseTable( + id=table_id, + rows=laid, + loop=loop, + name=name, + additive=self.additive, + ) diff --git a/src/sampletones_core/formats/bitphase/notes.py b/src/sampletones_core/formats/bitphase/notes.py index 0bc9e8735..675e77bc6 100644 --- a/src/sampletones_core/formats/bitphase/notes.py +++ b/src/sampletones_core/formats/bitphase/notes.py @@ -44,27 +44,28 @@ def note_index_to_note_cell(index: int) -> NoteCell: def noise_period_to_note_index(period: int) -> int: """Converts a noise period index to the note index that selects it. - Playback reads a noise note as ``15 - (index mod 16)``, so every period repeats once - per sixteen note indices and any of those indices selects it. The base index sits - far enough below the top of the tuning table that a whole cycle of table offsets - stays in range. + The project counts noise periods from the slowest and the register counts them from the + fastest, so a period reaches the register as ``15 - period``. Playback writes + ``15 - (index mod 16)`` to the register for a noise note, so the note index carries the + period itself, wrapped into the sixteen the channel has. Every period repeats once per + sixteen note indices, and the base index sits far enough below the top of the tuning + table that a whole cycle of table offsets stays in range. Args: - period: Noise period index the reconstruction chose. + period: Noise period index the reconstruction chose, or one a transpose moved. Returns: - int: Note index whose noise period equals ``period``. + int: Note index that sounds ``period``. """ - offset = (NUM_PERIODS - 1 - period) % NUM_PERIODS - return NOISE_BASE_NOTE_INDEX + offset + return NOISE_BASE_NOTE_INDEX + period % NUM_PERIODS def noise_arpeggio_to_table_offset(step: int) -> int: """Converts a noise arpeggio step to the semitone offset a table row carries. - A rising noise period is a falling note index, so the step is negated and wrapped - into one period cycle, which keeps every note the table reaches inside the tuning - table. + The note index carries the period itself (see :func:`noise_period_to_note_index`), so a + step moves the index by as much, wrapped into one period cycle, which keeps every note + the table reaches inside the tuning table. Args: step: Period offset from the reconstruction's initial noise period. @@ -72,4 +73,4 @@ def noise_arpeggio_to_table_offset(step: int) -> int: Returns: int: Semitone offset that moves the noise period by ``step``. """ - return (-step) % NUM_PERIODS + return step % NUM_PERIODS diff --git a/src/sampletones_core/formats/bitphase/specification/effects.py b/src/sampletones_core/formats/bitphase/specification/effects.py index 01dd8aafd..7be9facca 100644 --- a/src/sampletones_core/formats/bitphase/specification/effects.py +++ b/src/sampletones_core/formats/bitphase/specification/effects.py @@ -3,16 +3,21 @@ class EffectId(IntEnum): - """Identifier an effect column carries, as the code point of the letter Bitphase prints. + """Identifier an effect column carries: the code point of the letter Bitphase prints, or the digit itself. ``SPEED`` states how many engine ticks the row it sits on lasts, taken from the effect's own parameter or, where the effect names a table, from one table entry per pattern row. + ``ORNAMENT_POSITION`` places the channel's table at the step its parameter names, on the + row's own first tick. """ + ORNAMENT_POSITION = 5 SPEED = ord("S") 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 diff --git a/src/sampletones_core/formats/bitphase/specification/instruments.py b/src/sampletones_core/formats/bitphase/specification/instruments.py index 91f6e12d7..bc8505dc3 100644 --- a/src/sampletones_core/formats/bitphase/specification/instruments.py +++ b/src/sampletones_core/formats/bitphase/specification/instruments.py @@ -47,5 +47,6 @@ NO_SWEEP: Final[bool] = False LOOP_FROM_START: Final[int] = 0 +FIRST_TABLE_STEP: Final[int] = 0 NO_TABLE_OFFSET: Final[int] = 0 ABSOLUTE_TABLE: Final[bool] = False diff --git a/src/sampletones_core/formats/bitphase/transposes.py b/src/sampletones_core/formats/bitphase/transposes.py new file mode 100644 index 000000000..94ff4c6de --- /dev/null +++ b/src/sampletones_core/formats/bitphase/transposes.py @@ -0,0 +1,225 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Dict, Final, Optional, Tuple + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.exporters.rows.transpose import SoundingNote, sounding_notes +from sampletones_core.formats.bitphase.model.pattern import EffectCell +from sampletones_core.formats.bitphase.model.table import BitphaseTable +from sampletones_core.formats.bitphase.specification.effects import ( + MAX_ORNAMENT_POSITION, + NO_EFFECT_TABLE, + ORNAMENT_POSITION_DELAY, + EffectId, +) +from sampletones_core.formats.bitphase.specification.instruments import FIRST_TABLE_STEP, MAX_TABLE_ID +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 + +NOTE_SHIFT: Final[int] = 0 +MOVED_TABLE_NAME: Final[str] = "{name} {shift:+d}" +STARTED_TABLE_NAME: Final[str] = "{name} {shift:+d} from {start}" +NO_EFFECTS: Final[Tuple[Optional[EffectCell], ...]] = (None,) + +TableKey = Tuple[int, int, int] + + +@dataclass(frozen=True) +class TransposeCell: + """The columns a transpose row writes: the table that moves the note, and where it starts. + + Attributes: + table: The table column naming the moved table. + effects: The row's effect columns, placing the table at the step the note has reached. + """ + + table: int + effects: Tuple[Optional[EffectCell], ...] + + +@dataclass +class _TableShelf: + """The moved tables a document holds, numbered from the first id its other tables leave free. + + Attributes: + first_id: The id the first moved table takes. + tables: The moved tables, by the slice, the shift and the step each opens on. + """ + + first_id: int + tables: Dict[TableKey, BitphaseTable] = field(default_factory=dict) + + def table(self, voice: SliceVoice, shift: int, start: int) -> BitphaseTable: + """The slice's table moved by ``shift`` and opening on ``start``, shared by every row asking for it. + + A shift of none opening on the first step is the slice's own table. + + Raises: + ValueError: If the table would take an id past the ones the table column names. + """ + if shift == NOTE_SHIFT and start == FIRST_TABLE_STEP: + return voice.table + + key = (voice.number, shift, start) + if key not in self.tables: + table_id = self.first_id + len(self.tables) + if table_id > MAX_TABLE_ID: + raise ValueError( + f"Document exceeds the Bitphase limit of {MAX_TABLE_ID + 1} tables " + f"once its transpose rows take tables of their own" + ) + + self.tables[key] = voice.table.moved( + table_id=table_id, + shift=shift, + start=start, + name=self._name(voice, shift, start), + ) + + return self.tables[key] + + @staticmethod + def _name(voice: SliceVoice, shift: int, start: int) -> str: + if start == FIRST_TABLE_STEP: + return MOVED_TABLE_NAME.format(name=voice.table.name, shift=shift) + + return STARTED_TABLE_NAME.format(name=voice.table.name, shift=shift, start=start) + + +def _ornament_position(position: int) -> Tuple[Optional[EffectCell], ...]: + """The effect columns placing a newly attached table at ``position``, which the first step needs none of.""" + if position == FIRST_TABLE_STEP: + return NO_EFFECTS + + return ( + EffectCell( + effect=int(EffectId.ORNAMENT_POSITION), + delay=ORNAMENT_POSITION_DELAY, + parameter=position, + table_index=NO_EFFECT_TABLE, + ), + ) + + +@dataclass(frozen=True) +class TransposePlan: + """The cells a document's transpose rows write, and the moved tables those cells name. + + Bitphase restarts the instrument on a note or an instrument cell, and a table cell alone attaches + a table at its first step while the instrument plays on. A transpose row therefore names a copy + of the note's table moved by the distance between the note its transpose would write and the + note already written, and an ornament-position effect places that copy at the step the note's + own table has reached. From that tick the channel sounds what a note-on at the new transpose + would sound, with the instrument's envelopes going on where they stood. + + Attributes: + cells: Per channel, the cell each transpose row writes, keyed by its frame and row. + tables: The moved tables the cells name, in id order. + """ + + cells: Dict[ChannelName, Dict[Tuple[int, int], TransposeCell]] + tables: Tuple[BitphaseTable, ...] + + @classmethod + def build( + cls, + song: Song, + voices: SliceVoiceTable, + groove: Groove, + *, + 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 + 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. + + 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. + first_table_id: The id the first moved table takes, above every other table. + + Returns: + TransposePlan: The cells and the moved tables they name. + + Raises: + ValueError: If the moved tables run past the ids the table column names. + """ + shelf = _TableShelf(first_id=first_table_id) + cells: Dict[ChannelName, Dict[Tuple[int, int], TransposeCell]] = {} + for channel_name in ChannelName.items(): + channel_cells: Dict[Tuple[int, int], TransposeCell] = {} + for note in sounding_notes(song, channel_name, voices): + channel_cells.update( + cls._note_cells( + note, + voices[(note.voice_id, channel_name)], + groove, + shelf, + ) + ) + + cells[channel_name] = channel_cells + + return cls(cells=cells, tables=tuple(shelf.tables.values())) + + def frame_cells(self, channel_name: ChannelName, order_position: int) -> Dict[int, TransposeCell]: + """The cells one frame's transpose rows on a channel write, keyed by row.""" + return { + row_index: cell + for (position, row_index), cell in self.cells[channel_name].items() + if position == order_position + } + + @staticmethod + def _note_cells( + note: SoundingNote, + voice: SliceVoice, + groove: Groove, + shelf: _TableShelf, + ) -> Dict[Tuple[int, int], TransposeCell]: + """The cells the transpose rows reaching one note write.""" + written = voice.note_index(note.transpose) + carried = NOTE_SHIFT + cells: Dict[Tuple[int, int], TransposeCell] = {} + for repitch in note.repitches: + shift = voice.note_index(repitch.transpose) - written + if shift == carried: + continue + + carried = shift + position = voice.table.position_at(groove.ticks_across(note.place.row_index, repitch.rows)) + cells[(repitch.place.order_position, repitch.place.row_index)] = TransposePlan._attached( + voice, + shift, + position, + shelf, + ) + + return cells + + @staticmethod + def _attached( + voice: SliceVoice, + shift: int, + position: int, + shelf: _TableShelf, + ) -> TransposeCell: + """The cell attaching the slice's table moved by ``shift`` at the step ``position``. + + An ornament position places the moved table where it names a step, and a table opening on + the step itself carries a later one. + """ + if position <= MAX_ORNAMENT_POSITION: + table = shelf.table(voice, shift, FIRST_TABLE_STEP) + return TransposeCell(table=table.id + TABLE_COLUMN_OFFSET, effects=_ornament_position(position)) + + table = shelf.table(voice, shift, position) + return TransposeCell(table=table.id + TABLE_COLUMN_OFFSET, effects=NO_EFFECTS) diff --git a/src/sampletones_core/formats/bitphase/voices.py b/src/sampletones_core/formats/bitphase/voices.py new file mode 100644 index 000000000..fa03c70b9 --- /dev/null +++ b/src/sampletones_core/formats/bitphase/voices.py @@ -0,0 +1,57 @@ +from dataclasses import dataclass +from typing import Dict, Tuple + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.exporters.rows.pitch import highest_step, written_pitch +from sampletones_core.formats.bitphase.model.instrument import BitphaseInstrument +from sampletones_core.formats.bitphase.model.table import BitphaseTable +from sampletones_core.formats.bitphase.notes import noise_period_to_note_index, pitch_to_note_index + + +@dataclass(frozen=True) +class SliceVoice: + """One built instrument together with the table and the note that triggers it. + + Attributes: + number: Value a pattern's instrument column carries to play the instrument. + instrument: The macros the channel reads a value per tick from. + table: The per-tick semitone contour that moves the note. + channel: The NES channel the slice was reconstructed for. + initial_pitch: Pitch the slice's contour is measured against. + ticks: How many ticks the instrument runs before every dimension stands at its end. + """ + + number: int + instrument: BitphaseInstrument + table: BitphaseTable + channel: ChannelName + initial_pitch: int + ticks: int + + @property + def contour_top(self) -> int: + """The highest semitone step the slice's table moves its note by.""" + return highest_step(self.table.rows) + + def note_index(self, transpose: int) -> int: + """The note index a row triggering the slice at ``transpose`` writes. + + The noise channel reads its note as a period selector, so its transposed period takes the + index that sounds that period. Every other channel reads the tuning table at the note its + table moves, so the transposed pitch is written at the note that keeps the contour where the + song plays it — see :func:`written_pitch`. + + Args: + transpose: The semitone offset the row moves the slice by, or the period offset on noise. + + Returns: + int: The index the row's note column names. + """ + pitch = self.initial_pitch + transpose + if self.channel == ChannelName.NOISE: + return noise_period_to_note_index(pitch) + + return pitch_to_note_index(written_pitch(pitch, self.contour_top)) + + +SliceVoiceTable = Dict[Tuple[str, ChannelName], SliceVoice] diff --git a/src/sampletones_core/formats/famitracker/builder.py b/src/sampletones_core/formats/famitracker/builder.py index ca6556bea..bc8a5b5ab 100644 --- a/src/sampletones_core/formats/famitracker/builder.py +++ b/src/sampletones_core/formats/famitracker/builder.py @@ -1,11 +1,11 @@ -from typing import List, Optional, Sequence, Tuple +from typing import Final, FrozenSet, List, Mapping, Optional, Sequence, Tuple from sampletones_core.constants.enums import ChannelName from sampletones_core.exporters.feature import Features -from sampletones_core.exporters.skipped import BuiltDocument, find_skipped_rows +from sampletones_core.exporters.rows.levels import cell_volume, full_level_notes +from sampletones_core.exporters.skipped import BuiltDocument, find_skipped_rows, in_song_order from sampletones_core.exporters.slices import ( InstrumentEntry, - InstrumentSlot, InstrumentTable, iterate_instrument_entries, ) @@ -19,15 +19,12 @@ Track, ) from sampletones_core.formats.famitracker.model.pattern import PatternData, RowCell -from sampletones_core.formats.famitracker.notes import ( - period_to_note_cell, - pitch_to_note_cell, - resolve_machine, -) +from sampletones_core.formats.famitracker.notes import resolve_machine from sampletones_core.formats.famitracker.sequences.features import ( features_to_instrument_sequences, features_truncation, ) +from sampletones_core.formats.famitracker.slides import PatternCell, SlidePlan, slide_effect from sampletones_core.formats.famitracker.specification.channels import ( CHANNEL_COUNT_2A03, CHANNEL_TO_ID, @@ -56,6 +53,7 @@ MIN_OCTAVE, NoteValue, ) +from sampletones_core.formats.famitracker.targets import RowTargets, row_targets from sampletones_core.project.patterns.channel import Channel from sampletones_core.project.patterns.row import Row from sampletones_core.project.project import Project @@ -64,11 +62,15 @@ from sampletones_core.project.voices.note_on import NoteOn from sampletones_shared.application import SAMPLETONES_COPYRIGHT +NO_REPITCHED_INSTRUMENTS: Final[FrozenSet[int]] = frozenset() + def build_instrument( index: int, name: str, features: Features, + *, + repitched: bool, ) -> Instrument2A03: """Builds one FamiTracker instrument from a set of envelopes. @@ -79,11 +81,12 @@ def build_instrument( index: The slot the instrument is numbered under. name: The name FamiTracker lists the instrument by. features: The per-dimension envelopes the sequences are read from. + repitched: Whether a transpose row's note slide reaches the instrument in a module. Returns: The instrument the envelopes describe. """ - sequences = features_to_instrument_sequences(features) + sequences = features_to_instrument_sequences(features, repitched=repitched) return Instrument2A03( index=index, @@ -102,12 +105,19 @@ def build_instrument_table(project: Project) -> Tuple[List[Instrument2A03], Inst Raises: ValueError: If the project holds more instruments than FamiTracker has room for. """ - return _instrument_table(tuple(iterate_instrument_entries(project))) + return _instrument_table(tuple(iterate_instrument_entries(project)), NO_REPITCHED_INSTRUMENTS) -def _instrument_table(entries: Sequence[InstrumentEntry]) -> Tuple[List[Instrument2A03], InstrumentTable]: +def _instrument_table( + entries: Sequence[InstrumentEntry], + repitched: FrozenSet[int], +) -> Tuple[List[Instrument2A03], InstrumentTable]: """Builds the instruments the entries describe and the table a pattern row resolves through. + Args: + entries: The instruments to build, in the order the module numbers them. + repitched: The instruments a transpose row's note slide reaches. + Raises: ValueError: If the entries hold more instruments than FamiTracker has room for. """ @@ -123,6 +133,7 @@ def _instrument_table(entries: Sequence[InstrumentEntry]) -> Tuple[List[Instrume entry.index, entry.name, entry.features, + repitched=entry.index in repitched, ) ) for channel, slot in entry.slots.items(): @@ -131,54 +142,46 @@ def _instrument_table(entries: Sequence[InstrumentEntry]) -> Tuple[List[Instrume return instruments, slots -def _note_and_octave( - transpose: int, - channel_generator: ChannelName, - slot: InstrumentSlot, -) -> Tuple[int, int]: - base_pitch = slot.initial_pitch + transpose - if channel_generator == ChannelName.NOISE: - cell = period_to_note_cell(base_pitch) - else: - cell = pitch_to_note_cell(base_pitch) - - return cell.note, cell.octave - - def _row_cell( row: Row, row_number: int, channel_generator: ChannelName, - slots: InstrumentTable, + targets: RowTargets, + slide: Optional[int], + *, + full_level: bool, ) -> Optional[RowCell]: """Converts one tracker line to the cell that plays it, and ``None`` where the line is empty. A note-on naming a voice with no instrument on this channel plays nothing in the song, so it - becomes the note cut that silences the channel. + becomes the note cut that silences the channel. The volume column states what + :func:`cell_volume` gives the row, which is the full level on a note FamiTracker would otherwise + start at the level the channel carries. A transpose row moving the note sounding writes the note + slide ``slide`` names — see :class:`SlidePlan`. """ note = EMPTY_NOTE octave = MIN_OCTAVE instrument = EMPTY_INSTRUMENT - volume = row.volume if row.volume is not None else EMPTY_VOLUME + stated_volume = cell_volume(row, channel_generator, full_level=full_level) + volume = stated_volume if stated_volume is not None else EMPTY_VOLUME match row.command: case NoteOff(): note = int(NoteValue.HALT) case NoteOn() as reference: - slot = slots.get((reference.voice_id, channel_generator)) - if slot is None: + target = targets.get((reference.voice_id, channel_generator)) + if target is None: note = int(NoteValue.HALT) else: - instrument = slot.index - note, octave = _note_and_octave( - row.transpose or 0, - channel_generator, - slot, - ) + instrument = target.slot.index + cell_note = target.note_cell(row.transpose or 0, channel_generator) + note, octave = cell_note.note, cell_note.octave case None: pass effects = tuple((EMPTY_EFFECT, EMPTY_EFFECT_PARAM) for _ in range(DEFAULT_EFFECT_COLUMNS)) + if slide is not None: + effects = (slide_effect(slide),) + effects[1:] cell = RowCell( row_number=row_number, note=note, @@ -199,11 +202,24 @@ def _has_data(cell: RowCell) -> bool: ) +def _full_level_cells(song: Song, channel_name: ChannelName) -> FrozenSet[PatternCell]: + """The pattern cells of one channel that write the full level, by pattern index and row. + + A module stores a pattern once for every frame that plays it, so a cell writes the full level + wherever any of those frames reaches its note carrying another, which FamiTracker plays as the + frames come and again from the first once the order ends. + """ + return frozenset((place.pattern_index, place.row_index) for place in full_level_notes(song, channel_name)) + + def _channel_patterns( name: ChannelName, channel: Channel, - slots: InstrumentTable, + targets: RowTargets, + full_level_cells: FrozenSet[PatternCell], + slides: Mapping[PatternCell, int], ) -> List[PatternData]: + """Converts one channel's pattern pool, writing the full level and the note slides on the cells named.""" channel_id = CHANNEL_TO_ID[name] patterns: List[PatternData] = [] @@ -219,7 +235,9 @@ def _channel_patterns( row, row_number, name, - slots, + targets, + slides.get((index, row_number)), + full_level=(index, row_number) in full_level_cells, ) for row_number, row in enumerate(pattern.rows) ) @@ -277,14 +295,19 @@ def build_module(project: Project) -> BuiltDocument[FamiTrackerModule]: A row naming a voice on a channel the voice has no instrument for plays nothing in the song, so the module holds a note cut there and the row is listed beside it. A dimension longer than a sequence holds keeps its opening items, and the instruments shortened that way are reported - beside the rows. + beside the rows. The rows write their notes and levels so FamiTracker plays them where the + song's own walk does. A row moving the transpose of a note already sounding writes a note slide, + and a row the module has no slide for is listed beside the others — see :class:`SlidePlan`. Raises: ValueError: If the project holds more than FamiTracker has room for. """ entries = tuple(iterate_instrument_entries(project)) - instruments, slots = _instrument_table(entries) song = project.song + plain_instruments, slots = _instrument_table(entries, NO_REPITCHED_INSTRUMENTS) + targets = row_targets(plain_instruments, slots) + slides = SlidePlan.build(song, targets) + instruments, _ = _instrument_table(entries, slides.repitched) settings = project.settings info = project.info @@ -311,7 +334,9 @@ def build_module(project: Project) -> BuiltDocument[FamiTrackerModule]: _channel_patterns( channel, song.channels[channel], - slots, + targets, + _full_level_cells(song, channel), + slides.slides[channel], ), ) @@ -333,6 +358,6 @@ def build_module(project: Project) -> BuiltDocument[FamiTrackerModule]: track=track, comment=info.comment, ), - skipped_rows=find_skipped_rows(song, slots), + skipped_rows=in_song_order(find_skipped_rows(song, slots) + slides.skipped_rows), truncation=EnvelopeTruncation.summarize([features_truncation(entry.features) for entry in entries]), ) diff --git a/src/sampletones_core/formats/famitracker/footprint.py b/src/sampletones_core/formats/famitracker/footprint.py index 4fbe3a6a1..dc23cdd09 100644 --- a/src/sampletones_core/formats/famitracker/footprint.py +++ b/src/sampletones_core/formats/famitracker/footprint.py @@ -79,7 +79,7 @@ def features_footprint(features: Features) -> InstrumentFootprint: Returns: InstrumentFootprint: The footprint of the instrument those envelopes describe. """ - sequences = features_to_instrument_sequences(features) + sequences = features_to_instrument_sequences(features, repitched=False) return sequences_footprint(sequences.values()) diff --git a/src/sampletones_core/formats/famitracker/module.py b/src/sampletones_core/formats/famitracker/module.py index d27592e7b..cc75d43c2 100644 --- a/src/sampletones_core/formats/famitracker/module.py +++ b/src/sampletones_core/formats/famitracker/module.py @@ -153,7 +153,7 @@ def _write_patterns_block(writer: FamiTrackerWriter, patterns: Sequence[PatternD body.write_int8(row.volume) for effect, param in row.effects: body.write_int8(effect) - body.write_int8(param) + body.write_uint8(param) def _write_dpcm_samples_block(writer: FamiTrackerWriter) -> None: diff --git a/src/sampletones_core/formats/famitracker/sequences/features.py b/src/sampletones_core/formats/famitracker/sequences/features.py index a128e8a87..f1bd56d84 100644 --- a/src/sampletones_core/formats/famitracker/sequences/features.py +++ b/src/sampletones_core/formats/famitracker/sequences/features.py @@ -19,9 +19,14 @@ ) NO_ARPEGGIO_STEP: Final[int] = 0 +FLAT_RUNNING_ARPEGGIO: Final[Envelope[int]] = Envelope[int](items=(NO_ARPEGGIO_STEP,), loop_point=LOOP_FROM_START) -def features_to_instrument_sequences(features: Features) -> Dict[SequenceKind, InstrumentSequence]: +def features_to_instrument_sequences( + features: Features, + *, + repitched: bool, +) -> Dict[SequenceKind, InstrumentSequence]: """Builds the five 2A03 sequences from a channel slice's envelopes. Each dimension becomes an :class:`InstrumentSequence`; one the generator lacks, or one left @@ -31,15 +36,20 @@ def features_to_instrument_sequences(features: Features) -> Dict[SequenceKind, I 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`. + 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`. Args: features: The per-dimension envelopes describing the slice. + repitched: Whether a transpose row's note slide reaches the instrument in a module. Returns: Dict[SequenceKind, InstrumentSequence]: The sequences, one per dimension FamiTracker holds. """ stored = _pinned(_stored_envelopes(features)) + if repitched: + stored = _running(stored) + return {kind: _sequence(kind, stored.get(kind, Envelope[int]())) for kind in SequenceKind} @@ -133,11 +143,48 @@ def _pinning_arpeggio(stored: Dict[SequenceKind, Envelope[int]], bend_length: in return arpeggio if not arpeggio.items: - return Envelope[int](items=(NO_ARPEGGIO_STEP,), loop_point=LOOP_FROM_START) + return FLAT_RUNNING_ARPEGGIO return arpeggio.resized(max(len(arpeggio.items), bend_length)) +def _running(stored: Dict[SequenceKind, Envelope[int]]) -> Dict[SequenceKind, Envelope[int]]: + """These sequences with the arpeggio and the bend circling on their last item, so each runs as long as the note. + + A note slide moves the channel's note, and an arpeggio in absolute mode reloads the period from the + note every tick it runs, so the moved note sounds from the slide's own tick. A halted arpeggio + reloads nothing and the slide's glide is heard instead, so the arpeggio circles on its last item, + which is the note it rests on anyway, and an instrument writing none takes one item at its own + note. The bend is added to the period the arpeggio reloads, and a halted bend adds nothing, so a + bend circles on its last item too and holds the offset it ends on. + + Args: + stored: The sequences as the file holds them, the arpeggio already covering the bend. + + Returns: + Dict[SequenceKind, Envelope[int]]: Those sequences, the arpeggio and the bend running. + """ + arpeggio = stored.get(SequenceKind.ARPEGGIO, Envelope[int]()) + running = { + **stored, + SequenceKind.ARPEGGIO: _circling(arpeggio) if arpeggio.items else FLAT_RUNNING_ARPEGGIO, + } + for kind in BEND_SEQUENCE_KINDS: + bend = stored.get(kind, Envelope[int]()) + if bend.items: + running[kind] = _circling(bend) + + return running + + +def _circling(envelope: Envelope[int]) -> Envelope[int]: + """The envelope repeating from its own point, or from its last item where it plays once and holds it.""" + if envelope.loops: + return envelope + + return Envelope[int](items=envelope.items, loop_point=len(envelope.items) - 1) + + def _stored_envelopes(features: Features) -> Dict[SequenceKind, Envelope[int]]: """Each dimension the slice offers, as the file holds it.""" return { diff --git a/src/sampletones_core/formats/famitracker/slides.py b/src/sampletones_core/formats/famitracker/slides.py new file mode 100644 index 000000000..6f27428d0 --- /dev/null +++ b/src/sampletones_core/formats/famitracker/slides.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Dict, Final, FrozenSet, List, Set, Tuple + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.exporters.rows.transpose import Repitch, SoundingNote, sounding_notes +from sampletones_core.exporters.skipped import SkippedRow, SkipReason +from sampletones_core.formats.famitracker.specification.patterns import ( + FASTEST_SLIDE_SPEED, + MAX_SLIDE_SEMITONES, + SLIDE_SPEED_SHIFT, + EffectId, +) +from sampletones_core.formats.famitracker.targets import RowTargets +from sampletones_core.project.song import Song + +NO_SLIDE: Final[int] = 0 + +PatternCell = Tuple[int, int] +SlideEffect = Tuple[int, int] + + +def slide_effect(semitones: int) -> SlideEffect: + """The effect column moving a channel's note by ``semitones`` at the highest speed: ``Qxy`` up, ``Rxy`` down. + + Args: + semitones: How far the note moves, within ``MAX_SLIDE_SEMITONES`` either way. + + Returns: + SlideEffect: The effect number and its parameter. + """ + effect = EffectId.SLIDE_UP if semitones > NO_SLIDE else EffectId.SLIDE_DOWN + return int(effect), (FASTEST_SLIDE_SPEED << SLIDE_SPEED_SHIFT) | abs(semitones) + + +@dataclass +class _ChannelSlides: + """One channel's pass through the notes transpose rows move, deciding each pattern cell once. + + A module stores a pattern once for every frame that plays it, so a cell carries one slide + wherever the order reaches it. The first frame reaching a cell while a note sounds decides it, + and a later frame needing another slide there is reported. + """ + + channel_name: ChannelName + targets: RowTargets + decided: Dict[PatternCell, int] = field(default_factory=dict) + skipped: List[SkippedRow] = field(default_factory=list) + repitched: Set[int] = field(default_factory=set) + + def follow(self, note: SoundingNote) -> None: + """Moves the note the channel holds through each transpose row reaching it. + + FamiTracker keeps the note a slide leaves, so each row's slide is measured from the note the + channel holds once the slides before it applied, and a row the module leaves without its + slide leaves the note where it was for the rows after it. + """ + target = self.targets[(note.voice_id, self.channel_name)] + held = target.cell_pitch(note.transpose, self.channel_name) + for repitch in note.repitches: + needed = target.cell_pitch(repitch.transpose, self.channel_name) - held + applied = self._decide(repitch, needed) + if applied != needed: + self._report(note, repitch) + + if applied != NO_SLIDE: + self.repitched.add(target.slot.index) + + held += applied + + def _decide(self, repitch: Repitch, needed: int) -> int: + """The slide the row's cell carries, decided by the first frame that reaches it with a note.""" + cell = (repitch.place.pattern_index, repitch.place.row_index) + if cell not in self.decided: + self.decided[cell] = needed if abs(needed) <= MAX_SLIDE_SEMITONES else NO_SLIDE + + return self.decided[cell] + + def _report(self, note: SoundingNote, repitch: Repitch) -> None: + self.skipped.append( + SkippedRow( + voice_id=note.voice_id, + channel=self.channel_name, + order_position=repitch.place.order_position, + row_index=repitch.place.row_index, + reason=SkipReason.UNREACHED_TRANSPOSE, + ) + ) + + @property + def slides(self) -> Dict[PatternCell, int]: + """The cells that move the note, by pattern index and row.""" + return {cell: semitones for cell, semitones in self.decided.items() if semitones != NO_SLIDE} + + +@dataclass(frozen=True) +class SlidePlan: + """The note slides a module's transpose rows write, the rows it leaves without one, and the instruments they reach. + + FamiTracker's ``Qxy`` and ``Rxy`` move the channel's note by up to fifteen semitones at once and glide + the period toward it. While an instrument's arpeggio in absolute mode runs, it reloads the period + from the note every tick, which makes the move instant and exact. A transpose row therefore writes + the slide from the note the channel holds to the note a note-on at the row's transpose would write, + at the highest speed, and every instrument a slide reaches keeps its arpeggio running — see + :func:`features_to_instrument_sequences`. On noise the note is the period, and both notes lie within + the sixteen periods, so every noise slide is within reach. + + Attributes: + slides: Per channel, the semitones each pattern cell moves the note by, keyed by pattern index + and row. + skipped_rows: The transpose rows the module plays without their slide, once for every frame + that plays one so. + repitched: The instruments a slide reaches. + """ + + slides: Dict[ChannelName, Dict[PatternCell, int]] + skipped_rows: Tuple[SkippedRow, ...] + repitched: FrozenSet[int] + + @classmethod + def build(cls, song: Song, targets: RowTargets) -> SlidePlan: + """Plans every transpose row of the song, following the notes the order sounds. + + A row moving the note further than a slide reaches, or a cell of a pattern several frames play + needing another slide than the frame that decided it, is written without it and reported. + + Args: + song: The arrangement being exported. + targets: What a row naming a voice on a channel triggers. + + Returns: + SlidePlan: The slides, the rows left without one, and the instruments the slides reach. + """ + slides: Dict[ChannelName, Dict[PatternCell, int]] = {} + skipped: List[SkippedRow] = [] + repitched: Set[int] = set() + for channel_name in ChannelName.items(): + channel = _ChannelSlides(channel_name=channel_name, targets=targets) + for note in sounding_notes(song, channel_name, targets): + channel.follow(note) + + slides[channel_name] = channel.slides + skipped.extend(channel.skipped) + repitched |= channel.repitched + + return cls( + slides=slides, + skipped_rows=tuple(skipped), + repitched=frozenset(repitched), + ) diff --git a/src/sampletones_core/formats/famitracker/specification/patterns.py b/src/sampletones_core/formats/famitracker/specification/patterns.py index 6ec10ea7f..60ec08a83 100644 --- a/src/sampletones_core/formats/famitracker/specification/patterns.py +++ b/src/sampletones_core/formats/famitracker/specification/patterns.py @@ -19,6 +19,22 @@ class NoteValue(IntEnum): HALT = 14 +class EffectId(IntEnum): + """Effect number a pattern cell stores, as vanilla FamiTracker 0.4.6 numbers its effects. + + ``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``). + """ + + SLIDE_UP = 20 + SLIDE_DOWN = 21 + + +MAX_SLIDE_SEMITONES: Final[int] = 0x0F +FASTEST_SLIDE_SPEED: Final[int] = 0x0F +SLIDE_SPEED_SHIFT: Final[int] = 4 + EMPTY_NOTE: Final[int] = 0 EMPTY_INSTRUMENT: Final[int] = 0x40 EMPTY_VOLUME: Final[int] = 0x10 diff --git a/src/sampletones_core/formats/famitracker/targets.py b/src/sampletones_core/formats/famitracker/targets.py new file mode 100644 index 000000000..1b2862545 --- /dev/null +++ b/src/sampletones_core/formats/famitracker/targets.py @@ -0,0 +1,84 @@ +from dataclasses import dataclass +from typing import Dict, Sequence, Tuple + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.general import NUM_PERIODS +from sampletones_core.exporters.rows.pitch import highest_step, written_pitch +from sampletones_core.exporters.slices import InstrumentSlot, InstrumentTable +from sampletones_core.formats.famitracker.model.instrument import Instrument2A03 +from sampletones_core.formats.famitracker.model.pattern import NoteCell +from sampletones_core.formats.famitracker.notes import period_to_note_cell, pitch_to_note_cell +from sampletones_core.formats.famitracker.specification.patterns import FT_MAX_PITCH, FT_MIN_PITCH +from sampletones_core.formats.famitracker.specification.sequences import SequenceKind + + +@dataclass(frozen=True) +class RowTarget: + """The instrument a row naming a voice on one channel triggers, with what places its note. + + Attributes: + slot: The instrument's position in the module and the reference a row's transpose steps from. + contour_top: The highest semitone step the instrument's arpeggio moves the note by. + """ + + slot: InstrumentSlot + contour_top: int + + def cell_pitch(self, transpose: int, channel_name: ChannelName) -> int: + """The note a cell triggering the instrument at ``transpose`` names, which the channel then holds. + + The noise channel reads its note as a period, wrapped into the sixteen it has. Every other + channel reads the note its arpeggio moves, so the transposed pitch is written at the note that + keeps the contour where the song plays it — see :func:`written_pitch` — within the notes a + cell names. + + Args: + transpose: The row's transpose, measured from the instrument's reference. + channel_name: The channel the row stands on. + + Returns: + int: The pitch the cell names, or the period on noise. + """ + pitch = self.slot.initial_pitch + transpose + if channel_name == ChannelName.NOISE: + return pitch % NUM_PERIODS + + return max(FT_MIN_PITCH, min(FT_MAX_PITCH, written_pitch(pitch, self.contour_top))) + + def note_cell(self, transpose: int, channel_name: ChannelName) -> NoteCell: + """The note column a row triggering the instrument at ``transpose`` writes.""" + pitch = self.cell_pitch(transpose, channel_name) + if channel_name == ChannelName.NOISE: + return period_to_note_cell(pitch) + + return pitch_to_note_cell(pitch) + + +RowTargets = Dict[Tuple[str, ChannelName], RowTarget] + + +def row_targets( + instruments: Sequence[Instrument2A03], + slots: InstrumentTable, +) -> RowTargets: + """Pairs every slot a row resolves through with the highest step its instrument's arpeggio reaches. + + The arpeggio is read as the module stores it, so the step is one FamiTracker plays. + + Args: + instruments: The module's instruments. + slots: The slot a row naming a voice on a channel resolves through. + + Returns: + RowTargets: What each row naming a voice on a channel triggers. + """ + contour_tops = { + instrument.index: highest_step(instrument.sequences[SequenceKind.ARPEGGIO].items) for instrument in instruments + } + return { + key: RowTarget( + slot=slot, + contour_top=contour_tops[slot.index], + ) + for key, slot in slots.items() + } diff --git a/src/sampletones_core/performance/modifiers.py b/src/sampletones_core/performance/modifiers.py index e4f7a9d1a..06b180763 100644 --- a/src/sampletones_core/performance/modifiers.py +++ b/src/sampletones_core/performance/modifiers.py @@ -1,4 +1,6 @@ -from sampletones_core.constants.general import MAX_VOLUME +from typing import Final + +from sampletones_core.constants.general import MAX_VOLUME, MIN_VOLUME, SILENT_VOLUME from sampletones_core.instructions import ( InstructionUnion, NoiseInstruction, @@ -7,6 +9,64 @@ ) from sampletones_core.utils.frequencies import transpose_period, transpose_pitch +TRIANGLE_LOUDEST_SILENT_VOLUME: Final[int] = MAX_VOLUME // 2 + + +def triangle_sounds_at(row_volume: int) -> bool: + """Whether the triangle sounds at the level a pattern has reached. + + The triangle plays at one fixed loudness, so a pattern's level decides only whether it sounds, + and it sounds while the row asks for more than half volume. A tracker export writes the + triangle's volume column by this rule, so the tracker gates the triangle where the song does. + + Args: + row_volume: The level the pattern has reached. + + Returns: + bool: Whether the triangle sounds at that level. + """ + return row_volume > TRIANGLE_LOUDEST_SILENT_VOLUME + + +def pulse_volume( + volume: int, + row_volume: int, +) -> int: + """The level a pulse channel sounds at: the instruction's level scaled by the row's, to the nearest step. + + Args: + volume: The level the instruction holds. + row_volume: The level the pattern has reached. + + Returns: + int: The level the channel sounds at. + """ + return max(SILENT_VOLUME, min(MAX_VOLUME, round(volume * row_volume / MAX_VOLUME))) + + +def noise_volume( + volume: int, + row_volume: int, +) -> int: + """The level the noise channel sounds at: the instruction's level scaled by the row's, rounded down. + + A product that rounds down to silence while both levels sound is raised to the quietest level, so a + quiet row keeps the noise audible. FamiTracker and Bitphase both set the noise level by this rule, + so an exported song's noise plays there at the level it plays here. + + Args: + volume: The level the instruction holds. + row_volume: The level the pattern has reached. + + Returns: + int: The level the channel sounds at. + """ + scaled = max(SILENT_VOLUME, min(MAX_VOLUME, volume * row_volume // MAX_VOLUME)) + if scaled == SILENT_VOLUME and volume > SILENT_VOLUME and row_volume > SILENT_VOLUME: + return MIN_VOLUME + + return scaled + def apply_modifiers( instruction: InstructionUnion, @@ -17,9 +77,9 @@ def apply_modifiers( A sample carries the instructions it was reconstructed from; a pattern states how loud and how high it is played. Each channel takes both in the terms it understands: the pulse channels - scale their volume and shift their pitch, the triangle shifts its pitch and sounds while the - row asks for more than half volume, and the noise channel scales its volume and walks its - period around the sixteen the hardware offers. + scale their volume by :func:`pulse_volume` and shift their pitch, the triangle shifts its pitch + and sounds while the row asks for more than half volume, and the noise channel scales its + volume by :func:`noise_volume` and walks its period around the sixteen the hardware offers. Args: instruction: The tick's instruction as the sample holds it. @@ -31,14 +91,14 @@ def apply_modifiers( """ match instruction: case PulseInstruction(): - scaled_volume = max(0, min(MAX_VOLUME, round(instruction.volume * row_volume / MAX_VOLUME))) + scaled_volume = pulse_volume(instruction.volume, row_volume) effective_pitch = transpose_pitch(instruction.pitch, transpose) return instruction.model_copy(update={"pitch": effective_pitch, "volume": scaled_volume}) case TriangleInstruction(): effective_pitch = transpose_pitch(instruction.pitch, transpose) - on = instruction.on and row_volume > MAX_VOLUME // 2 + on = instruction.on and triangle_sounds_at(row_volume) return instruction.model_copy(update={"pitch": effective_pitch, "on": on}) case NoiseInstruction(): - scaled_volume = max(0, min(MAX_VOLUME, round(instruction.volume * row_volume / MAX_VOLUME))) + scaled_volume = noise_volume(instruction.volume, row_volume) effective_period = transpose_period(instruction.period, transpose) return instruction.model_copy(update={"period": effective_period, "volume": scaled_volume}) diff --git a/src/sampletones_core/performance/rows.py b/src/sampletones_core/performance/rows.py index b6956aa7c..e24b4978c 100644 --- a/src/sampletones_core/performance/rows.py +++ b/src/sampletones_core/performance/rows.py @@ -47,9 +47,9 @@ def apply_row(performance: ChannelPerformance, row: Row) -> bool: """Moves a channel onto the row it has reached, and reports whether the note starts over. A note column names the voice to sound and begins it, taking the transpose and volume the row - states or the defaults where it states neither. A row naming no note leaves the voice playing - and changes only the columns it fills in, which is how a transpose or a volume bends a note - already sounding. + states or the defaults where it states neither, with every envelope dimension at the value a + song starts on. A row naming no note leaves the voice playing and changes only the columns it + fills in, which is how a transpose or a volume bends a note already sounding. Args: performance: What the channel carries; updated in place. @@ -60,10 +60,11 @@ def apply_row(performance: ChannelPerformance, row: Row) -> bool: """ match row.command: case NoteOn() as note_on: - performance.voice_id = note_on.voice_id - performance.tick_index = 0 - performance.transpose = row.transpose if row.transpose is not None else 0 - performance.volume = row.volume if row.volume is not None else MAX_VOLUME + performance.start_note( + note_on.voice_id, + transpose=row.transpose if row.transpose is not None else 0, + volume=row.volume if row.volume is not None else MAX_VOLUME, + ) return True case NoteOff(): performance.voice_id = None diff --git a/src/sampletones_core/performance/state.py b/src/sampletones_core/performance/state.py index a7930074c..9b4f87253 100644 --- a/src/sampletones_core/performance/state.py +++ b/src/sampletones_core/performance/state.py @@ -16,8 +16,9 @@ class ChannelPerformance: sustain across rows. The channel carries a value per envelope dimension too, which is what an instrument leaving a - dimension to the channel sounds at. A frame the instrument writes hands its value over, so the - channel keeps the last one written for as long as the song runs. + dimension to the channel sounds at. Every note starts them from the values a song starts on, + and a frame the instrument writes hands its value over, so the channel keeps the last one + written for as long as the note sounds. Attributes: voice_id: The voice the channel is sounding, or ``None`` while it is silent. @@ -33,6 +34,31 @@ class ChannelPerformance: volume: int = field(default=MAX_VOLUME) feature_values: Dict[FeatureKey, int] = field(default_factory=CHANNEL_FEATURE_DEFAULTS.copy) + def start_note( + self, + voice_id: str, + *, + transpose: int, + volume: int, + ) -> None: + """Begins a note of ``voice_id`` from its first tick, at the transpose and volume given. + + The envelope dimensions start from the values a song starts on: full volume, no arpeggio + offset, no bend and the first timbre. A dimension the voice leaves empty therefore sounds + the same on every note, wherever the note stands in the song, which is how FamiTracker and + Bitphase start a note and what keeps a tracker export playing it as the song does. + + Args: + voice_id: The voice the note sounds. + transpose: The semitone offset the note plays at. + volume: The level the note plays at. + """ + self.voice_id = voice_id + self.tick_index = 0 + self.transpose = transpose + self.volume = volume + self.feature_values = CHANNEL_FEATURE_DEFAULTS.copy() + def reset(self) -> None: """Returns the channel to silence at full volume, as a song starts it. diff --git a/src/sampletones_core/performance/voice.py b/src/sampletones_core/performance/voice.py index 129bb1feb..659c8ef8f 100644 --- a/src/sampletones_core/performance/voice.py +++ b/src/sampletones_core/performance/voice.py @@ -17,8 +17,9 @@ class VoiceReading: A voice carries a frame per tick stating every dimension the channel reads, and names which of those dimensions it writes itself. The rest are the channel's own: the voice leaves an empty - envelope for them and the channel sounds them at the value it holds, which is what clearing an - envelope in the instruments panel means once the voice is played in a song. + envelope for them and the channel sounds them at the value it holds, which every note starts + where a song starts it. That is what clearing an envelope in the instruments panel means once + the voice is played in a song. Reading a voice on a channel answers all a channel needs of it — the frames, the reference its arpeggio is measured against, the dimensions it leaves behind, and what it sounds once the diff --git a/src/sampletones_core/project/voices/envelopes.py b/src/sampletones_core/project/voices/envelopes.py index 8ef13feaa..a20725ff0 100644 --- a/src/sampletones_core/project/voices/envelopes.py +++ b/src/sampletones_core/project/voices/envelopes.py @@ -29,8 +29,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 - empty envelope leaves that dimension to the channel, which keeps the value it already holds — - the same record a reconstruction's held dimensions carry. + 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. Attributes: volume: Output level per tick. diff --git a/src/sampletones_core/reconstructions/reconstruction/instructions.py b/src/sampletones_core/reconstructions/reconstruction/instructions.py index b21167044..369c3bd93 100644 --- a/src/sampletones_core/reconstructions/reconstruction/instructions.py +++ b/src/sampletones_core/reconstructions/reconstruction/instructions.py @@ -27,7 +27,7 @@ class InstructionsItem(DataModel): ) held_features: List[FeatureKey] = Field( ..., - description="Dimensions the channel governs, keeping the value it holds while the channel sounds", + description="Dimensions the channel governs, sounding at the value every note starts on", ) @model_validator(mode="after") diff --git a/src/sampletones_core/reconstructions/reconstruction/reconstruction.py b/src/sampletones_core/reconstructions/reconstruction/reconstruction.py index bc5d912e4..df327acea 100644 --- a/src/sampletones_core/reconstructions/reconstruction/reconstruction.py +++ b/src/sampletones_core/reconstructions/reconstruction/reconstruction.py @@ -228,8 +228,8 @@ def held_features(self) -> Dict[ChannelName, Tuple[FeatureKey, ...]]: """The dimensions each channel governs, whose envelopes an export leaves empty. An instrument writes the dimensions it describes and leaves the rest to the channel, - which keeps the value it already holds for as long as the instrument sounds. These - are the dimensions it leaves. + which sounds them at the value every note starts on for as long as the instrument sounds. + These are the dimensions it leaves. """ return {channel_name: tuple(item.held_features) for channel_name, item in self.streams.items()} diff --git a/src/sampletones_core/reconstructions/reconstruction/stems/removal.py b/src/sampletones_core/reconstructions/reconstruction/stems/removal.py index c85d1c6b3..b717ddf70 100644 --- a/src/sampletones_core/reconstructions/reconstruction/stems/removal.py +++ b/src/sampletones_core/reconstructions/reconstruction/stems/removal.py @@ -1,4 +1,4 @@ -from typing import Dict, List, Sequence +from typing import Dict, List, Optional, Sequence from sampletones_core.constants.algorithm import RESTING_STEM_ID from sampletones_core.constants.enums import ChannelName @@ -8,6 +8,23 @@ from sampletones_core.reconstructions.reconstruction.stems.channel_assignment import ChannelAssignment +def can_remove_stem(reconstruction: Reconstruction, stem_id: int) -> bool: + """Whether a removal of ``stem_id`` applies to the reconstruction as it stands. + + A removal asked for a moment before it runs meets the document the steps before it left, which + may have let that recording go already or left it the last one standing. The answer follows + the guard :func:`without_stem` holds to, so a removal asked about first always succeeds. + + Args: + reconstruction: The reconstruction the recording would be taken out of. + stem_id: The stems entry to remove. + + Returns: + bool: True where ``stem_id`` names a recorded entry and another entry stands beside it. + """ + return _removal_refusal(reconstruction, stem_id) is None + + def without_stem(reconstruction: Reconstruction, stem_id: int) -> Reconstruction: """The reconstruction with one recording taken out, the frames it held left resting. @@ -33,14 +50,11 @@ def without_stem(reconstruction: Reconstruction, stem_id: int) -> Reconstruction Raises: ValueError: If ``stem_id`` names no recorded entry, or names the last one standing. """ - stems_data = reconstruction.stems_data - config = stems_data.config - if stem_id not in config.entries_by_id: - raise ValueError(f"Stem {stem_id} names no entry of the recorded setup") - - if len(config.entries) == 1: - raise ValueError("A reconstruction holds at least one stem") + refusal = _removal_refusal(reconstruction, stem_id) + if refusal is not None: + raise ValueError(refusal) + stems_data = reconstruction.stems_data released = {item.channel_name: [held == stem_id for held in item.stem_ids] for item in stems_data.assignments} return reconstruction.rewritten( _released_streams(reconstruction, released), @@ -50,6 +64,18 @@ def without_stem(reconstruction: Reconstruction, stem_id: int) -> Reconstruction ) +def _removal_refusal(reconstruction: Reconstruction, stem_id: int) -> Optional[str]: + """Why a removal of ``stem_id`` does not apply to the reconstruction, or ``None`` where it does.""" + config = reconstruction.stems_data.config + if stem_id not in config.entries_by_id: + return f"Stem {stem_id} names no entry of the recorded setup" + + if len(config.entries) == 1: + return "A reconstruction holds at least one stem" + + return None + + def _released_assignment(item: ChannelAssignment, released: Sequence[bool]) -> ChannelAssignment: """The channel's per-frame ownership with each released frame resting.""" return ChannelAssignment( diff --git a/src/sampletones_core/timing/groove.py b/src/sampletones_core/timing/groove.py index 541ff32bc..459819f53 100644 --- a/src/sampletones_core/timing/groove.py +++ b/src/sampletones_core/timing/groove.py @@ -43,6 +43,24 @@ 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, diff --git a/src/sampletones_shared/utils/system/paths.py b/src/sampletones_shared/utils/system/paths.py index 2af8faa36..dc4c3b3e1 100644 --- a/src/sampletones_shared/utils/system/paths.py +++ b/src/sampletones_shared/utils/system/paths.py @@ -49,6 +49,23 @@ def to_path(path: GeneralPathlike) -> Path: return Path(path) +def is_same_path(first: Path, second: Path) -> bool: + """ + Whether two paths name the same location, however each of them is spelled. + + A file is reached through a relative path, a detour through a parent, or a link, and each + spelling resolves to the one absolute location compared here. The file itself may be absent. + + Args: + first (Path): One spelling of a location. + second (Path): Another spelling to compare it with. + + Returns: + bool: Whether both resolve to the same location. + """ + return first.resolve() == second.resolve() + + def get_filename(name: str, extension: str) -> str: """ Composes a file name from the name a thing is known by and its extension. diff --git a/src/sampletones_tools/compatibility/documents.py b/src/sampletones_tools/compatibility/documents.py index 98c6f47d1..3cdfac75d 100644 --- a/src/sampletones_tools/compatibility/documents.py +++ b/src/sampletones_tools/compatibility/documents.py @@ -2,7 +2,6 @@ from typing import Any, Dict, Final, List, Sequence, Tuple from sampletones_core.configs import Config -from sampletones_core.constants.algorithm import RESTING_STEM_ID from sampletones_core.constants.enums import ChannelName, SpectrumMethod from sampletones_core.fft import Window from sampletones_core.fft.features import get_feature_extractor @@ -23,11 +22,8 @@ from sampletones_core.project.voices.note_on import NoteOn from sampletones_core.project.voices.sample import Sample from sampletones_core.reconstructions import Reconstruction -from sampletones_core.reconstructions.reconstruction.stems.channel_assignment import ChannelAssignment -from sampletones_core.reconstructions.reconstruction.stems.data import StemsData -from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings +from sampletones_tools.corpus.written import written_reconstruction -SINGLE_STEM_ID: Final[int] = 0 SOURCE_PATH: Final[Path] = Path("samples") / "kick.wav" COEFFICIENT: Final[float] = 0.75 RECORDING_SCALE: Final[float] = 0.5 @@ -76,31 +72,13 @@ def corpus_instructions() -> Dict[ChannelName, List[InstructionUnion]]: } -def stems_record(instructions: Dict[ChannelName, List[InstructionUnion]]) -> StemsData: - """The single-entry record a classic conversion writes, one owner per frame, and the scale it read at. - - A frame that sounds answers to the recording; a silent frame answers to rest, which is the - rule a reconstruction holds its record to. - """ - channels = list(instructions) - assignments = [ - ChannelAssignment( - channel_name=channel_name, - stem_ids=[SINGLE_STEM_ID if instruction.on else RESTING_STEM_ID for instruction in stream], - ) - for channel_name, stream in instructions.items() - ] - return StemsData.single_entry(StemSettings.covering(channels), assignments, RECORDING_SCALE) - - def corpus_reconstruction(instructions: Dict[ChannelName, List[InstructionUnion]]) -> Reconstruction: """A reconstruction naming every channel, its source recording, and one owner per frame.""" - return Reconstruction.create( - instructions=instructions, - config=Config(), + return written_reconstruction( + instructions, coefficient=COEFFICIENT, + scale=RECORDING_SCALE, audio_filepath=(SOURCE_PATH,), - stems_data=stems_record(instructions), ) @@ -116,12 +94,11 @@ def embedded_reconstruction() -> Reconstruction: PulseInstruction(on=False, pitch=40, volume=0, duty_cycle=0), ] } - return Reconstruction.create( - instructions=instructions, - config=Config(), + return written_reconstruction( + instructions, coefficient=COEFFICIENT, + scale=RECORDING_SCALE, audio_filepath=(), - stems_data=stems_record(instructions), ) diff --git a/src/sampletones_tools/corpus/build.py b/src/sampletones_tools/corpus/build.py index 1eb460bf4..54e31705e 100644 --- a/src/sampletones_tools/corpus/build.py +++ b/src/sampletones_tools/corpus/build.py @@ -1,11 +1,12 @@ from dataclasses import dataclass from pathlib import Path from tempfile import TemporaryDirectory -from typing import Dict +from typing import Dict, Mapping 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.project.voices.voice import VoiceUnion from sampletones_tools.corpus.catalog import CatalogSpec, build_catalog from sampletones_tools.corpus.module import ModuleConfig from sampletones_tools.corpus.song import SongSpec, build_song @@ -26,11 +27,11 @@ class Corpus: def build_project( - catalog: Dict[str, Sample], + catalog: Mapping[str, VoiceUnion], module_config: ModuleConfig, song_spec: SongSpec, ) -> Project: - """The arrangement playing the catalog under the module's identity and playback settings.""" + """The arrangement playing the catalog's voices under the module's identity and playback settings.""" settings = ProjectSettings( tempo=module_config.tempo, speed=module_config.speed, @@ -41,8 +42,8 @@ def build_project( author=module_config.author, settings=settings, ) - for sample in catalog.values(): - project.voices.append(sample) + for voice in catalog.values(): + project.voices.append(voice) project.song = build_song(song_spec, catalog) return project diff --git a/src/sampletones_tools/corpus/config/song.yaml b/src/sampletones_tools/corpus/config/song.yaml index 92ec89c05..e3e8b757b 100644 --- a/src/sampletones_tools/corpus/config/song.yaml +++ b/src/sampletones_tools/corpus/config/song.yaml @@ -6,28 +6,28 @@ channels: pulse1: patterns: 0: - - { row: 0, sample: kick, transpose: 0, volume: 15 } + - { row: 0, voice: kick, transpose: 0, volume: 15 } - { row: 2, "off": true } - - { row: 4, sample: kick, transpose: 12, volume: 12 } + - { row: 4, voice: kick, transpose: 12, volume: 12 } - { row: 6, volume: 8 } pulse2: patterns: 0: - - { row: 0, sample: lead, transpose: 0, volume: 12 } - - { row: 4, sample: lead, transpose: 7, volume: 10 } + - { row: 0, voice: lead, transpose: 0, volume: 12 } + - { row: 4, voice: lead, transpose: 7, volume: 10 } triangle: patterns: 0: - - { row: 0, sample: kick, transpose: -12, volume: 15 } + - { row: 0, voice: kick, transpose: -12, volume: 15 } 1: - - { row: 0, sample: kick, transpose: 7, volume: 10 } - - { row: 4, sample: kick, transpose: 0, volume: 10 } + - { row: 0, voice: kick, transpose: 7, volume: 10 } + - { row: 4, voice: kick, transpose: 0, volume: 10 } noise: patterns: 0: - - { row: 0, sample: hihat, volume: 15 } - - { row: 2, sample: hihat, volume: 10 } - - { row: 4, sample: hihat, volume: 15 } - - { row: 6, sample: hihat, volume: 8 } + - { row: 0, voice: hihat, volume: 15 } + - { row: 2, voice: hihat, volume: 10 } + - { row: 4, voice: hihat, volume: 15 } + - { row: 6, voice: hihat, volume: 8 } 1: - - { row: 0, sample: hihat, transpose: 3, volume: 12 } + - { row: 0, voice: hihat, transpose: 3, volume: 12 } diff --git a/src/sampletones_tools/corpus/song.py b/src/sampletones_tools/corpus/song.py index 5ae846682..ef6d241af 100644 --- a/src/sampletones_tools/corpus/song.py +++ b/src/sampletones_tools/corpus/song.py @@ -9,7 +9,7 @@ from sampletones_core.project.song import Song from sampletones_core.project.voices.note_off import NoteOff from sampletones_core.project.voices.note_on import NoteOn -from sampletones_core.project.voices.sample import Sample +from sampletones_core.project.voices.voice import VoiceUnion, voice_channels from sampletones_shared.utils.serialization import load_yaml_model from sampletones_tools.corpus.paths import SONG_PATH @@ -19,8 +19,8 @@ class RowSpec(BaseModel): Attributes: row: The row's index in its pattern. - sample: The sample the row plays, or ``None`` for a row that plays none. - transpose: The semitones the row transposes the sample by, or ``None`` to leave it. + voice: The voice the row plays, or ``None`` for a row that plays none. + transpose: The semitones the row transposes the voice by, or ``None`` to leave it. volume: The volume the row sets, or ``None`` to leave it. off: Whether the row releases the note. """ @@ -28,7 +28,7 @@ class RowSpec(BaseModel): model_config = ConfigDict(frozen=True, extra="forbid") row: int - sample: Optional[str] = None + voice: Optional[str] = None transpose: Optional[int] = None volume: Optional[int] = None off: bool = False @@ -63,19 +63,19 @@ def _order( return [{channel: frame.get(channel) for channel in ChannelName.items()} for frame in frames] -def _row(spec: RowSpec, channel: ChannelName, samples_by_name: Mapping[str, Sample]) -> Row: +def _row(spec: RowSpec, channel: ChannelName, voices_by_name: Mapping[str, VoiceUnion]) -> Row: if spec.off: return Row(command=NoteOff(), volume=spec.volume) - if spec.sample is None: + if spec.voice is None: return Row(transpose=spec.transpose, volume=spec.volume) - sample = samples_by_name[spec.sample] - if channel not in sample.reconstruction.playing_channels: - raise ValueError(f"Sample '{spec.sample}' has no '{channel.value}' slice for the {channel.value} channel") + voice = voices_by_name[spec.voice] + if channel not in voice_channels(voice): + raise ValueError(f"Voice '{spec.voice}' has no '{channel.value}' slice for the {channel.value} channel") return Row( - command=NoteOn(voice_id=sample.id), + command=NoteOn(voice_id=voice.id), transpose=spec.transpose, volume=spec.volume, ) @@ -85,11 +85,11 @@ def _pattern( row_specs: Sequence[RowSpec], rows_per_pattern: int, channel: ChannelName, - samples_by_name: Mapping[str, Sample], + voices_by_name: Mapping[str, VoiceUnion], ) -> Pattern: rows = [Row() for _ in range(rows_per_pattern)] for spec in row_specs: - rows[spec.row] = _row(spec, channel, samples_by_name) + rows[spec.row] = _row(spec, channel, voices_by_name) return Pattern(rows=rows) @@ -97,7 +97,7 @@ def _pattern( def _channels( channel_specs: Mapping[ChannelName, ChannelSpec], rows_per_pattern: int, - samples_by_name: Mapping[str, Sample], + voices_by_name: Mapping[str, VoiceUnion], ) -> Dict[ChannelName, Channel]: channels: Dict[ChannelName, Channel] = {} for channel, spec in channel_specs.items(): @@ -106,7 +106,7 @@ def _channels( row_specs, rows_per_pattern, channel, - samples_by_name, + voices_by_name, ) for index, row_specs in spec.patterns.items() } @@ -118,15 +118,15 @@ def _channels( return channels -def build_song(spec: SongSpec, samples_by_name: Mapping[str, Sample]) -> Song: - """The song the spec describes, playing the named samples. +def build_song(spec: SongSpec, voices_by_name: Mapping[str, VoiceUnion]) -> Song: + """The song the spec describes, playing the named voices. Raises: - KeyError: If a row names a sample the catalog lacks. - ValueError: If a row plays a sample on a channel the sample has no slice for. + KeyError: If a row names a voice the catalog lacks. + ValueError: If a row plays a voice on a channel the voice has no slice for. """ return Song( rows_per_pattern=spec.rows_per_pattern, order=_order(spec.order), - channels=_channels(spec.channels, spec.rows_per_pattern, samples_by_name), + channels=_channels(spec.channels, spec.rows_per_pattern, voices_by_name), ) diff --git a/src/sampletones_tools/corpus/written.py b/src/sampletones_tools/corpus/written.py new file mode 100644 index 000000000..153fafcad --- /dev/null +++ b/src/sampletones_tools/corpus/written.py @@ -0,0 +1,76 @@ +from pathlib import Path +from typing import Final, Mapping, Sequence, Tuple + +from sampletones_core.configs import Config +from sampletones_core.constants.algorithm import RESTING_STEM_ID +from sampletones_core.constants.enums import ChannelName +from sampletones_core.instructions import InstructionUnion +from sampletones_core.reconstructions import Reconstruction +from sampletones_core.reconstructions.reconstruction.stems.channel_assignment import ChannelAssignment +from sampletones_core.reconstructions.reconstruction.stems.data import StemsData +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings + +SINGLE_STEM_ID: Final[int] = 0 + + +def single_recording_record( + instructions: Mapping[ChannelName, Sequence[InstructionUnion]], + scale: float, +) -> StemsData: + """The single-entry record a classic conversion writes over the frames it chose. + + A frame that sounds answers to the one recording, and a silent frame answers to rest, which is + the rule a reconstruction holds its record to. + + Args: + instructions: What each channel plays, one instruction per frame. + scale: The level the recording was read at. + + Returns: + StemsData: The record naming one owner per frame. + """ + assignments = [ + ChannelAssignment( + channel_name=channel_name, + stem_ids=[SINGLE_STEM_ID if instruction.on else RESTING_STEM_ID for instruction in stream], + ) + for channel_name, stream in instructions.items() + ] + return StemsData.single_entry( + StemSettings.covering(list(instructions)), + assignments, + scale, + ) + + +def written_reconstruction( + instructions: Mapping[ChannelName, Sequence[InstructionUnion]], + *, + coefficient: float, + scale: float, + audio_filepath: Tuple[Path, ...], +) -> Reconstruction: + """A reconstruction playing the frames it is given, as though one conversion had chosen them. + + Writing the frames out by hand is what lets a corpus state exactly what each channel sounds on + every tick. + + Args: + instructions: What each channel plays, one instruction per frame. + coefficient: The working-level coefficient the recording is said to be scaled by. + scale: The level the recording is said to be read at. + audio_filepath: The recordings the frames are said to come from, empty for none. + + Returns: + Reconstruction: The document holding those frames and the record behind them. + """ + return Reconstruction.create( + instructions=instructions, + config=Config(), + coefficient=coefficient, + audio_filepath=audio_filepath, + stems_data=single_recording_record( + instructions, + scale, + ), + ) diff --git a/src/sampletones_tools/player/trace/trace.py b/src/sampletones_tools/player/trace/trace.py index e9cccfdc3..954aa1e82 100644 --- a/src/sampletones_tools/player/trace/trace.py +++ b/src/sampletones_tools/player/trace/trace.py @@ -1,9 +1,10 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Dict, Final, List, Tuple +from typing import Dict, Final, List, Sequence, Tuple from sampletones_core.constants.enums import ChannelName +from sampletones_player.registers.base import ChannelRegisters from sampletones_player.song import Song from sampletones_player.specification.channels import CHANNEL_REGISTER_ADDRESSES from sampletones_player.specification.registers import ( @@ -26,6 +27,47 @@ FIRST_TICK: Final[int] = 0 +def setup_writes() -> Tuple[RegisterWrite, ...]: + """The writes the init routine makes before it sounds the song's first tick. + + It clears every channel register, enables the four channels, runs the frame counter without an + interrupt, turns both sweep units off and loads the noise channel's length counter. + + Returns: + Tuple[RegisterWrite, ...]: The writes, in the order the routine makes them. + """ + writes = [ + RegisterWrite(address, SILENCED_REGISTER) + for address in range(FIRST_CHANNEL_REGISTER, LAST_CHANNEL_REGISTER + 1) + ] + writes.extend( + ( + RegisterWrite(APU_STATUS, CHANNELS_ENABLED), + RegisterWrite(APU_FRAME_COUNTER, FRAME_COUNTER_SEQUENCE), + RegisterWrite(PULSE1_SWEEP, SWEEP_DISABLED), + RegisterWrite(PULSE2_SWEEP, SWEEP_DISABLED), + RegisterWrite(NOISE_LENGTH_COUNTER, NOISE_LENGTH_COUNTER_LOAD), + ) + ) + return tuple(writes) + + +def channel_writes(registers: Sequence[ChannelRegisters]) -> Tuple[RegisterWrite, ...]: + """Every register the four channels' values of one tick reach, each channel in the order the driver writes it. + + Args: + registers: One register set per channel, in channel order. + + Returns: + Tuple[RegisterWrite, ...]: One write per register the channels own. + """ + return tuple( + RegisterWrite(address, value) + for channel, channel_registers in zip(ChannelName.items(), registers, strict=True) + for address, value in zip(CHANNEL_REGISTER_ADDRESSES[channel], channel_registers.values, strict=True) + ) + + @dataclass(frozen=True) class RegisterTrace: """Every APU register write a run of the driver makes, grouped by the call that makes it. @@ -59,31 +101,20 @@ def _tick_writes( shadows: Dict[int, int], ) -> Tuple[RegisterWrite, ...]: writes: List[RegisterWrite] = [] - for channel, registers in zip(ChannelName.items(), song.streams.at(tick)): - for address, value in zip(CHANNEL_REGISTER_ADDRESSES[channel], registers.values): - if address in REGISTERS_WRITTEN_ON_CHANGE: - if shadows.get(address) == value: - continue + for write in channel_writes(song.streams.at(tick)): + if write.address in REGISTERS_WRITTEN_ON_CHANGE: + if shadows.get(write.address) == write.value: + continue - shadows[address] = value + shadows[write.address] = write.value - writes.append(RegisterWrite(address, value)) + writes.append(write) return tuple(writes) @classmethod def _initialization_writes(cls, song: Song, shadows: Dict[int, int]) -> Tuple[RegisterWrite, ...]: - writes = [ - RegisterWrite(address, SILENCED_REGISTER) - for address in range(FIRST_CHANNEL_REGISTER, LAST_CHANNEL_REGISTER + 1) - ] - writes.append(RegisterWrite(APU_STATUS, CHANNELS_ENABLED)) - writes.append(RegisterWrite(APU_FRAME_COUNTER, FRAME_COUNTER_SEQUENCE)) - writes.append(RegisterWrite(PULSE1_SWEEP, SWEEP_DISABLED)) - writes.append(RegisterWrite(PULSE2_SWEEP, SWEEP_DISABLED)) - writes.append(RegisterWrite(NOISE_LENGTH_COUNTER, NOISE_LENGTH_COUNTER_LOAD)) - writes.extend(cls._tick_writes(song, FIRST_TICK, shadows)) - return tuple(writes) + return setup_writes() + cls._tick_writes(song, FIRST_TICK, shadows) @classmethod def from_song(cls, song: Song, play_calls: int) -> RegisterTrace: diff --git a/src/sampletones_tools/registry.py b/src/sampletones_tools/registry.py index 575fc896a..e6327a4c5 100644 --- a/src/sampletones_tools/registry.py +++ b/src/sampletones_tools/registry.py @@ -10,6 +10,7 @@ from sampletones_tools.samples.commands.btp import BTP from sampletones_tools.samples.commands.ftm import FTM from sampletones_tools.samples.commands.nsf import NSF +from sampletones_tools.tracker_playback.command import TRACKER_PLAYBACK DEVELOPER_COMMANDS: Final[Tuple[Command, ...]] = ( BTP, @@ -21,4 +22,5 @@ FTM, ICONS, NSF, + TRACKER_PLAYBACK, ) diff --git a/src/sampletones_tools/tracker_playback/__init__.py b/src/sampletones_tools/tracker_playback/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_tools/tracker_playback/command.py b/src/sampletones_tools/tracker_playback/command.py new file mode 100644 index 000000000..bf46fda6c --- /dev/null +++ b/src/sampletones_tools/tracker_playback/command.py @@ -0,0 +1,100 @@ +from argparse import ArgumentParser, Namespace +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Final, 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 + +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, " + "and report every tick a channel sounds differently from the app" +) +TARGET_FIELD: Final[str] = "target" +TARGET_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_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.""" + + target: str + output: Optional[Path] + + +def configure(parser: ArgumentParser) -> None: + targets = parser.add_subparsers( + dest=TARGET_FIELD, + metavar=TARGET_METAVAR, + required=True, + ) + for face in TARGETS: + target = targets.add_parser( + face.name, + help=face.help, + description=face.help, + ) + face.configure(target) + target.add_argument( + "--output", + "-o", + type=Path, + default=None, + help=OUTPUT_HELP, + ) + + +def run(arguments: Namespace) -> int: + """Plays the corpus through the target the command names, prints each verdict and the report's link. + + Raises: + SystemExit: If the target's tracker, or what runs it, is missing, or it fails to play a file. + """ + given = PlaybackArguments(target=arguments.target, 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, + default_output, + ) + from sampletones_tools.tracker_playback.settings import PlaybackSettings + from sampletones_tools.tracker_playback.targets.protocol import PlaybackError + + try: + target = face.locate(arguments) + outcome = check_corpus( + comparison_corpus(CorpusSpec.load()), + PlaybackRun( + target=target, + output=given.output if given.output is not None else default_output(), + settings=PlaybackSettings.load(), + ), + ) + except PlaybackError as error: + raise SystemExit(str(error)) from error + + for project in outcome.outcomes: + print(f"{project.project.name}: {result_label(project.comparison)}") + + print(f"Report: {outcome.report.resolve().as_uri()}") + return 0 + + +TRACKER_PLAYBACK: Final[Command] = Command( + name=NAME, + help=HELP, + configure=configure, + run=run, +) diff --git a/src/sampletones_tools/tracker_playback/commands/__init__.py b/src/sampletones_tools/tracker_playback/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_tools/tracker_playback/commands/bitphase.py b/src/sampletones_tools/tracker_playback/commands/bitphase.py new file mode 100644 index 000000000..44d130129 --- /dev/null +++ b/src/sampletones_tools/tracker_playback/commands/bitphase.py @@ -0,0 +1,52 @@ +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] = "bitphase" +HELP: Final[str] = "export each project to a Bitphase document (.btp) and play it with a Bitphase checkout's own engine" +CHECKOUT_HELP: Final[str] = "the Bitphase source checkout whose engine plays the documents, with its packages installed" + + +@dataclass(frozen=True) +class BitphaseArguments: + """What the Bitphase target is given: the checkout whose engine plays the documents.""" + + checkout: Path + + +def configure(parser: ArgumentParser) -> None: + parser.add_argument( + "--checkout", + type=Path, + required=True, + help=CHECKOUT_HELP, + ) + + +def locate(arguments: Namespace) -> PlaybackTarget: + """The Bitphase target playing documents with the checkout the options name. + + Raises: + EngineError: If node is absent, or the checkout lacks a file the trace loads. + """ + given = BitphaseArguments(checkout=arguments.checkout) + + from sampletones_tools.tracker_playback.targets.bitphase.target import BitphaseTarget + + return BitphaseTarget.located(given.checkout) + + +BITPHASE: Final[TargetFace] = TargetFace( + name=NAME, + help=HELP, + configure=configure, + locate=locate, +) diff --git a/src/sampletones_tools/tracker_playback/commands/face.py b/src/sampletones_tools/tracker_playback/commands/face.py new file mode 100644 index 000000000..49a1c0d6a --- /dev/null +++ b/src/sampletones_tools/tracker_playback/commands/face.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from argparse import ArgumentParser, Namespace +from dataclasses import dataclass +from typing import TYPE_CHECKING, Callable + +if TYPE_CHECKING: + from sampletones_tools.tracker_playback.targets.protocol import PlaybackTarget + + +@dataclass(frozen=True) +class TargetFace: + """The command-line face of one playback target: its name, its help, its options and how it is found. + + Listing the commands reads every face, so a face states its target's options and reaches the target + itself once a check runs. + + Attributes: + name: The target's name, as the command line spells it. + help: One line saying what the target exports to and what plays it. + configure: Adds the options the target needs, such as where its tracker lives. + locate: Builds the target from the parsed options, once its tracker is found. + """ + + name: str + help: str + configure: Callable[[ArgumentParser], None] + locate: Callable[[Namespace], PlaybackTarget] diff --git a/src/sampletones_tools/tracker_playback/comparison.py b/src/sampletones_tools/tracker_playback/comparison.py new file mode 100644 index 000000000..9cd69d6d9 --- /dev/null +++ b/src/sampletones_tools/tracker_playback/comparison.py @@ -0,0 +1,263 @@ +from dataclasses import dataclass, replace +from enum import StrEnum +from typing import Dict, List, Optional, Self, Tuple + +from sampletones_core.constants.enums import ChannelName +from sampletones_tools.tracker_playback.trace.sound import ChannelSound, SongTrace, TickPosition + + +class SoundField(StrEnum): + AUDIBLE = "audible" + PERIOD = "period" + VOLUME = "volume" + TIMBRE = "timbre" + + +@dataclass(frozen=True) +class DivergentTick: + """One tick a channel sounds differently on, with what each side sounds. + + Attributes: + tick: The tick, counted from the song's first. + position: Where the application places the tick. + application: What the application sounds. + engine: What the tracker sounds. + """ + + tick: int + position: TickPosition + application: ChannelSound + engine: ChannelSound + + +@dataclass(frozen=True) +class Divergence: + """One way a channel sounds differently in the tracker, from the first tick it shows onward. + + Ticks that differ in the same fields of the same channel share one divergence, so each field a + cause strikes is reported once with how often it strikes. Two causes striking the same field, + such as a pitch off by a step and a transpose left out, show apart in the examples: a tick joins + them where it opens a row the examples have yet to reach and the two sides sound values the last + example lacks. + + Attributes: + channel: The channel that sounds differently. + fields: What differs: whether it sounds at all, or which of its registers. + examples: The first tick it shows on, then the ticks that change what the sides sound. + ticks: How many ticks show the same difference. + """ + + channel: ChannelName + fields: Tuple[SoundField, ...] + examples: Tuple[DivergentTick, ...] + ticks: int + + @property + def first(self) -> DivergentTick: + """The first tick the difference shows on.""" + return self.examples[0] + + def counted( + self, + divergent: DivergentTick, + *, + examples: int, + ) -> Self: + """The divergence once one more tick shows it, that tick joining the examples where it tells more. + + Args: + divergent: The tick. + examples: How many examples the divergence keeps at most. + + Returns: + Self: The divergence, counting the tick. + """ + last = self.examples[-1] + telling = ( + len(self.examples) < examples + and divergent.position != last.position + and (divergent.application, divergent.engine) != (last.application, last.engine) + ) + return replace( + self, + examples=self.examples + (divergent,) if telling else self.examples, + ticks=self.ticks + 1, + ) + + +@dataclass(frozen=True) +class TimingDivergence: + """The first tick the two players place at different rows. + + Attributes: + tick: The tick. + position: Where the application places it. + engine_position: Where the tracker places it. + """ + + tick: int + position: TickPosition + engine_position: TickPosition + + +@dataclass(frozen=True) +class TraceComparison: + """How one pass through a song sounds in a tracker against how it sounds in the application. + + Attributes: + application_ticks: The ticks the application plays. + engine_ticks: The ticks the tracker plays. + timing: The first tick the two place at different rows, or ``None`` where every shared tick + falls on the same row. + divergences: Every way a channel sounds differently, in the order they first show. + """ + + application_ticks: int + engine_ticks: int + timing: Optional[TimingDivergence] + divergences: Tuple[Divergence, ...] + + @property + def matches(self) -> bool: + """Whether the tracker plays every tick the application plays, and sounds each alike.""" + return self.application_ticks == self.engine_ticks and self.timing is None and not self.divergences + + +def differing_fields( + application: ChannelSound, + engine: ChannelSound, +) -> Tuple[SoundField, ...]: + """What differs between two sounds of one channel on one tick. + + A channel silent on both sides sounds alike whatever its registers hold, and one that sounds on + one side alone differs in that and nothing further. The volume covers whether the chip holds the + level, so a channel its own envelope or counters move differs in volume from one it holds. + + Args: + application: What the application sounds. + engine: What the tracker sounds. + + Returns: + Tuple[SoundField, ...]: The fields that differ, empty where the two sound alike. + """ + if not application.audible and not engine.audible: + return () + + if application.audible != engine.audible: + return (SoundField.AUDIBLE,) + + compared = ( + (SoundField.PERIOD, application.period, engine.period), + (SoundField.VOLUME, (application.volume, application.held), (engine.volume, engine.held)), + (SoundField.TIMBRE, application.timbre, engine.timbre), + ) + return tuple(field for field, expected, played in compared if expected != played) + + +def first_timing_divergence( + application: SongTrace, + engine: SongTrace, +) -> Optional[TimingDivergence]: + """The first tick both players reach and place at different rows. + + Args: + application: What the application plays. + engine: What the tracker plays. + + Returns: + Optional[TimingDivergence]: That tick, or ``None`` where every shared tick falls alike. + """ + for tick, (position, engine_position) in enumerate(zip(application.positions, engine.positions)): + if position != engine_position: + return TimingDivergence( + tick=tick, + position=position, + engine_position=engine_position, + ) + + return None + + +def channel_divergences( + channel: ChannelName, + application: SongTrace, + engine: SongTrace, + *, + examples: int, +) -> List[Divergence]: + """Every way one channel sounds differently across the ticks both players reach. + + Args: + channel: The channel compared. + application: What the application plays. + engine: What the tracker plays. + examples: How many examples each divergence keeps at most. + + Returns: + List[Divergence]: One entry per set of differing fields, in the order they first show. + """ + found: Dict[Tuple[SoundField, ...], Divergence] = {} + shared = min(application.ticks, engine.ticks) + for tick in range(shared): + expected = application.channels[channel][tick] + played = engine.channels[channel][tick] + fields = differing_fields(expected, played) + if not fields: + continue + + divergent = DivergentTick( + tick=tick, + position=application.positions[tick], + application=expected, + engine=played, + ) + seen = found.get(fields) + found[fields] = ( + Divergence( + channel=channel, + fields=fields, + examples=(divergent,), + ticks=1, + ) + if seen is None + else seen.counted(divergent, examples=examples) + ) + + return list(found.values()) + + +def compare_traces( + application: SongTrace, + engine: SongTrace, + *, + examples: int, +) -> TraceComparison: + """Holds what a tracker plays of a song against what the application plays of it, tick by tick. + + Tick 0 is the song's first tick on both sides, so a tick index names the same moment wherever the + rows fall. Each tick of each channel is compared once both sides have been read into registers. + + Args: + application: What the application plays. + engine: What the tracker plays. + examples: How many examples each divergence keeps at most. + + Returns: + TraceComparison: The lengths, the first tick the rows part, and every divergence. + """ + divergences = [ + divergence + for channel in ChannelName.items() + for divergence in channel_divergences( + channel, + application, + engine, + examples=examples, + ) + ] + return TraceComparison( + application_ticks=application.ticks, + engine_ticks=engine.ticks, + timing=first_timing_divergence(application, engine), + divergences=tuple(sorted(divergences, key=lambda divergence: divergence.first.tick)), + ) diff --git a/src/sampletones_tools/tracker_playback/config/__init__.py b/src/sampletones_tools/tracker_playback/config/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_tools/tracker_playback/config/corpus.yaml b/src/sampletones_tools/tracker_playback/config/corpus.yaml new file mode 100644 index 000000000..8e87a7261 --- /dev/null +++ b/src/sampletones_tools/tracker_playback/config/corpus.yaml @@ -0,0 +1,447 @@ +voices: + square: + kind: sample + channels: + pulse1: + - { count: 48, frame: { "on": true, pitch: 60, volume: 15, duty_cycle: 2 } } + pulse2: + - { count: 48, frame: { "on": true, pitch: 67, volume: 12, duty_cycle: 1 } } + bass: + kind: sample + channels: + triangle: + - { count: 100, frame: { "on": true, pitch: 48 } } + hiss: + kind: sample + channels: + noise: + - { count: 48, frame: { "on": true, period: 6, volume: 14, short: false } } + buzz: + kind: sample + channels: + noise: + - { count: 24, frame: { "on": true, period: 11, volume: 12, short: true } } + contour: + kind: sample + channels: + pulse1: + - { count: 2, frame: { "on": true, pitch: 60, volume: 15, duty_cycle: 2 } } + - { count: 2, frame: { "on": true, pitch: 64, volume: 14, duty_cycle: 2, detune: 3 } } + - { count: 2, frame: { "on": true, pitch: 67, volume: 13, duty_cycle: 1, detune: -5 } } + - { count: 2, frame: { "on": true, pitch: 72, volume: 12, duty_cycle: 0, coarse_detune: 1 } } + - { count: 2, frame: { "on": true, pitch: 67, volume: 11, duty_cycle: 3, detune: 7, coarse_detune: -1 } } + - { count: 1, frame: { "on": false, pitch: 67, volume: 0, duty_cycle: 3 } } + - { count: 3, frame: { "on": true, pitch: 55, volume: 10, duty_cycle: 2, detune: -2 } } + - { count: 4, frame: { "on": true, pitch: 62, volume: 9, duty_cycle: 1 } } + pulse2: + - { count: 2, frame: { "on": true, pitch: 69, volume: 12, duty_cycle: 2, detune: -8 } } + - { count: 2, frame: { "on": true, pitch: 69, volume: 12, duty_cycle: 2, detune: -4 } } + - { count: 2, frame: { "on": true, pitch: 69, volume: 12, duty_cycle: 2 } } + - { count: 2, frame: { "on": true, pitch: 69, volume: 12, duty_cycle: 2, detune: 4 } } + - { count: 2, frame: { "on": true, pitch: 69, volume: 12, duty_cycle: 2, detune: 8 } } + - { count: 2, frame: { "on": true, pitch: 71, volume: 10, duty_cycle: 1, coarse_detune: 2 } } + - { count: 2, frame: { "on": true, pitch: 71, volume: 10, duty_cycle: 1, coarse_detune: -2 } } + - { count: 4, frame: { "on": true, pitch: 64, volume: 8, duty_cycle: 0 } } + triangle: + - { count: 3, frame: { "on": true, pitch: 45 } } + - { count: 3, frame: { "on": true, pitch: 45, detune: 6 } } + - { count: 3, frame: { "on": true, pitch: 52, detune: -4 } } + - { count: 3, frame: { "on": true, pitch: 57, coarse_detune: 1 } } + - { count: 2, frame: { "on": false, pitch: 57 } } + - { count: 4, frame: { "on": true, pitch: 40 } } + noise: + - { count: 2, frame: { "on": true, period: 3, volume: 15, short: false } } + - { count: 2, frame: { "on": true, period: 5, volume: 13, short: false } } + - { count: 2, frame: { "on": true, period: 8, volume: 11, short: true } } + - { count: 2, frame: { "on": true, period: 12, volume: 9, short: true } } + - { count: 2, frame: { "on": true, period: 15, volume: 7, short: false } } + - { count: 2, frame: { "on": true, period: 0, volume: 5, short: false } } + - { count: 2, frame: { "on": false, period: 0, volume: 0, short: false } } + - { count: 4, frame: { "on": true, period: 9, volume: 10, short: true } } + low: + kind: sample + channels: + pulse1: + - { count: 24, frame: { "on": true, pitch: 36, volume: 15, duty_cycle: 2 } } + triangle: + - { count: 24, frame: { "on": true, pitch: 36 } } + long: + kind: sample + channels: + pulse1: + - { count: 100, frame: { "on": true, pitch: 60, volume: 15, duty_cycle: 2 } } + - { count: 100, frame: { "on": true, pitch: 60, volume: 13, duty_cycle: 2 } } + - { count: 100, frame: { "on": true, pitch: 60, volume: 11, duty_cycle: 2 } } + - { count: 100, frame: { "on": true, pitch: 60, volume: 9, duty_cycle: 2 } } + - { count: 100, frame: { "on": true, pitch: 60, volume: 7, duty_cycle: 2 } } + - { count: 100, frame: { "on": true, pitch: 60, volume: 5, duty_cycle: 2 } } + lead: + kind: instrument + initial_pitch: 64 + initial_period: 5 + envelopes: + volume: { items: [15, 14, 13, 12, 11, 10, 9, 8] } + arpeggio: { items: [0, 4, 7], loop_point: 0 } + duty_cycle: { items: [0, 2], loop_point: 0 } + pitch: { items: [0, 3, 6, 3, 0, -3, -6, -3], loop_point: 0 } + pluck: + kind: instrument + initial_pitch: 57 + initial_period: 4 + envelopes: + volume: { items: [15, 12, 9, 6, 3, 0] } + hi_pitch: { items: [0, 1, 2] } + swell: + kind: instrument + initial_pitch: 52 + initial_period: 7 + envelopes: + arpeggio: { items: [0, 12], loop_point: 0 } + duty_cycle: { items: [1] } + +projects: + - name: every-channel + purpose: A note on every channel, each playing its frames out and falling silent once they end. + module: { title: Every channel, author: Tracker playback check, tempo: 150, speed: 6, nes_frequency: 60 } + voices: [square, bass, hiss] + song: + rows_per_pattern: 12 + order: + - { pulse1: 0, pulse2: 0, triangle: 0, noise: 0 } + channels: + pulse1: { patterns: { 0: [{ row: 0, voice: square, volume: 15 }] } } + pulse2: { patterns: { 0: [{ row: 0, voice: square, volume: 15 }] } } + triangle: { patterns: { 0: [{ row: 0, voice: bass, volume: 15 }] } } + noise: { patterns: { 0: [{ row: 0, voice: hiss, volume: 15 }] } } + + - name: note-after-quiet-row + purpose: A note whose row states no volume, after a row that lowered the channel's volume. + module: { title: Note after a quiet row, author: Tracker playback check, tempo: 150, speed: 6, nes_frequency: 60 } + voices: [square, hiss] + song: + rows_per_pattern: 8 + order: + - { pulse1: 0, pulse2: 0, noise: 0 } + channels: + pulse1: + patterns: + 0: + - { row: 0, voice: square, volume: 5 } + - { row: 4, voice: square } + pulse2: + patterns: + 0: + - { row: 0, voice: square } + noise: + patterns: + 0: + - { row: 0, voice: hiss, volume: 4 } + - { row: 4, voice: hiss } + + - name: volume-rows + purpose: Rows stating a volume alone, bending the level of a sounding note, silencing it and bringing it back. + module: { title: Volume rows, author: Tracker playback check, tempo: 150, speed: 6, nes_frequency: 60 } + voices: [square, hiss] + song: + rows_per_pattern: 8 + order: + - { pulse1: 0, noise: 0 } + channels: + pulse1: + patterns: + 0: + - { row: 0, voice: square, volume: 15 } + - { row: 1, volume: 12 } + - { row: 2, volume: 7 } + - { row: 3, volume: 0 } + - { row: 4, volume: 10 } + - { row: 5, volume: 3 } + - { row: 6, volume: 1 } + noise: + patterns: + 0: + - { row: 0, voice: hiss, volume: 15 } + - { row: 1, volume: 12 } + - { row: 2, volume: 7 } + - { row: 3, volume: 0 } + - { row: 4, volume: 10 } + - { row: 5, volume: 3 } + - { row: 6, volume: 1 } + + - name: transpose-rows + purpose: Rows stating a transpose and no note, which move the pitch of the note already sounding. + module: { title: Transpose rows, author: Tracker playback check, tempo: 150, speed: 6, nes_frequency: 60 } + voices: [square, bass, hiss] + song: + rows_per_pattern: 8 + order: + - { pulse1: 0, triangle: 0, noise: 0 } + channels: + pulse1: + patterns: + 0: + - { row: 0, voice: square, volume: 15 } + - { row: 2, transpose: 5 } + - { row: 4, transpose: -3 } + - { row: 6, transpose: 7, volume: 9 } + triangle: + patterns: + 0: + - { row: 0, voice: bass, volume: 15 } + - { row: 3, transpose: 12 } + - { row: 5, transpose: -5 } + noise: + patterns: + 0: + - { row: 0, voice: hiss, volume: 15 } + - { row: 2, transpose: 4 } + - { row: 4, transpose: 12 } + + - name: triangle-volumes + purpose: A sounding triangle under every row volume from 0 to 15. + module: { title: Triangle volumes, author: Tracker playback check, tempo: 150, speed: 6, nes_frequency: 60 } + voices: [bass] + song: + rows_per_pattern: 16 + order: + - { triangle: 0 } + channels: + triangle: + patterns: + 0: + - { row: 0, voice: bass, volume: 0 } + - { row: 1, volume: 1 } + - { row: 2, volume: 2 } + - { row: 3, volume: 3 } + - { row: 4, volume: 4 } + - { row: 5, volume: 5 } + - { row: 6, volume: 6 } + - { row: 7, volume: 7 } + - { row: 8, volume: 8 } + - { row: 9, volume: 9 } + - { row: 10, volume: 10 } + - { row: 11, volume: 11 } + - { row: 12, volume: 12 } + - { row: 13, volume: 13 } + - { row: 14, volume: 14 } + - { row: 15, volume: 15 } + + - name: noise-periods + purpose: Noise notes at several periods in both modes, with transposes wrapping past either end of the sixteen periods. + module: { title: Noise periods, author: Tracker playback check, tempo: 150, speed: 6, nes_frequency: 60 } + voices: [hiss, buzz] + song: + rows_per_pattern: 12 + order: + - { noise: 0 } + channels: + noise: + patterns: + 0: + - { row: 0, voice: hiss, volume: 15 } + - { row: 1, voice: hiss, transpose: 3, volume: 15 } + - { row: 2, voice: hiss, transpose: 9, volume: 15 } + - { row: 3, voice: hiss, transpose: 10, volume: 15 } + - { row: 4, voice: hiss, transpose: -6, volume: 15 } + - { row: 5, voice: hiss, transpose: -7, volume: 15 } + - { row: 6, voice: hiss, transpose: 25, volume: 15 } + - { row: 7, voice: hiss, transpose: -22, volume: 15 } + - { row: 8, voice: buzz, volume: 15 } + - { row: 9, voice: buzz, transpose: 4, volume: 15 } + - { row: 10, voice: buzz, transpose: 5, volume: 15 } + - { row: 11, voice: buzz, transpose: -12, volume: 15 } + + - name: note-offs + purpose: Note-offs on every channel, a volume row on a released channel, and notes after them. + module: { title: Note-offs, author: Tracker playback check, tempo: 150, speed: 6, nes_frequency: 60 } + voices: [square, bass, hiss] + song: + rows_per_pattern: 8 + order: + - { pulse1: 0, pulse2: 0, triangle: 0, noise: 0 } + channels: + pulse1: + patterns: + 0: + - { row: 0, voice: square, volume: 15 } + - { row: 2, "off": true } + - { row: 3, voice: square, volume: 11 } + - { row: 5, "off": true } + - { row: 6, volume: 10 } + - { row: 7, voice: square, volume: 13 } + pulse2: + patterns: + 0: + - { row: 1, voice: square, volume: 15 } + - { row: 4, "off": true } + - { row: 6, voice: square, volume: 9 } + triangle: + patterns: + 0: + - { row: 0, voice: bass, volume: 15 } + - { row: 2, "off": true } + - { row: 3, voice: bass, volume: 15 } + - { row: 5, "off": true } + - { row: 7, voice: bass, volume: 15 } + noise: + patterns: + 0: + - { row: 0, voice: hiss, volume: 15 } + - { row: 2, "off": true } + - { row: 3, voice: hiss, volume: 11 } + - { row: 5, "off": true } + - { row: 6, volume: 10 } + - { row: 7, voice: hiss, volume: 13 } + + - name: instrument-channels + purpose: Hand-written instruments on every channel, with looping envelopes, a release, and a volume left to the channel. + module: { title: Instruments on every channel, author: Tracker playback check, tempo: 150, speed: 6, nes_frequency: 60 } + voices: [lead, pluck, swell] + song: + rows_per_pattern: 16 + order: + - { pulse1: 0, pulse2: 0, triangle: 0, noise: 0 } + channels: + pulse1: + patterns: + 0: + - { row: 0, voice: lead, volume: 15 } + - { row: 8, voice: pluck, volume: 15 } + pulse2: + patterns: + 0: + - { row: 0, voice: swell, volume: 12 } + - { row: 8, voice: lead, transpose: 5, volume: 15 } + triangle: + patterns: + 0: + - { row: 0, voice: lead, transpose: -12, volume: 15 } + - { row: 8, voice: swell, volume: 15 } + noise: + patterns: + 0: + - { row: 0, voice: lead, volume: 15 } + - { row: 4, voice: pluck, volume: 15 } + - { row: 8, voice: swell, transpose: 3, volume: 10 } + - { row: 12, voice: lead, transpose: -4, volume: 15 } + + - name: sample-contours + purpose: A sample whose frames carry arpeggio contours, bends, rests and duty changes on every channel, played as written and transposed. + module: { title: Sample contours, author: Tracker playback check, tempo: 150, speed: 6, nes_frequency: 60 } + voices: [contour] + song: + rows_per_pattern: 8 + order: + - { pulse1: 0, pulse2: 0, triangle: 0, noise: 0 } + channels: + pulse1: + patterns: + 0: + - { row: 0, voice: contour, volume: 15 } + - { row: 4, voice: contour, transpose: 5, volume: 15 } + pulse2: + patterns: + 0: + - { row: 0, voice: contour, volume: 15 } + - { row: 4, voice: contour, transpose: -7, volume: 15 } + triangle: + patterns: + 0: + - { row: 0, voice: contour, volume: 15 } + - { row: 4, voice: contour, transpose: 12, volume: 15 } + noise: + patterns: + 0: + - { row: 0, voice: contour, volume: 15 } + - { row: 4, voice: contour, transpose: 2, volume: 15 } + + - name: groove-order + purpose: A tempo whose rows last unequal ticks, under an order that revisits patterns and leaves a channel out of a frame. + module: { title: Groove and order, author: Tracker playback check, tempo: 210, speed: 6, nes_frequency: 60 } + voices: [square, bass, hiss, buzz] + song: + rows_per_pattern: 16 + order: + - { pulse1: 0, pulse2: 1, triangle: 0, noise: 0 } + - { pulse1: 1, pulse2: 0, triangle: 1, noise: 0 } + - { pulse1: 0, triangle: 0, noise: 1 } + - { pulse1: 1, pulse2: 1, triangle: 1, noise: 1 } + channels: + pulse1: + patterns: + 0: + - { row: 0, voice: square, volume: 15 } + - { row: 8, voice: square, transpose: 7, volume: 12 } + 1: + - { row: 0, voice: square, transpose: -5, volume: 10 } + - { row: 12, "off": true } + pulse2: + patterns: + 0: + - { row: 4, voice: square, volume: 15 } + 1: + - { row: 0, voice: square, transpose: 12, volume: 11 } + - { row: 10, "off": true } + triangle: + patterns: + 0: + - { row: 0, voice: bass, volume: 15 } + 1: + - { row: 0, voice: bass, transpose: 7, volume: 15 } + - { row: 8, voice: bass, volume: 15 } + noise: + patterns: + 0: + - { row: 0, voice: hiss, volume: 15 } + - { row: 4, voice: hiss, volume: 12 } + - { row: 8, voice: hiss, volume: 15 } + - { row: 12, voice: hiss, volume: 12 } + 1: + - { row: 0, voice: buzz, volume: 15 } + - { row: 6, voice: buzz, transpose: 3, volume: 15 } + + - name: low-transpose + purpose: Transposes pushing a note below pitch 33, the lowest the application plays. + module: { title: Low transpose, author: Tracker playback check, tempo: 150, speed: 6, nes_frequency: 60 } + voices: [low] + song: + rows_per_pattern: 8 + order: + - { pulse1: 0, triangle: 0 } + channels: + pulse1: + patterns: + 0: + - { row: 0, voice: low, volume: 15 } + - { row: 2, voice: low, transpose: -5, volume: 15 } + - { row: 4, voice: low, transpose: -12, volume: 15 } + - { row: 6, voice: low, transpose: -3, volume: 15 } + triangle: + patterns: + 0: + - { row: 0, voice: low, transpose: -8, volume: 15 } + - { row: 4, voice: low, transpose: -2, volume: 15 } + + - name: long-slice + purpose: A slice whose volume changes over more values than a tracker instrument holds, which the export shortens and reports. + module: { title: Long slice, author: Tracker playback check, tempo: 150, speed: 6, nes_frequency: 60 } + voices: [long] + song: + rows_per_pattern: 64 + order: + - { pulse1: 0 } + - { pulse1: 1 } + channels: + pulse1: + patterns: + 0: + - { row: 0, voice: long, volume: 15 } + 1: [] + +arrangements: + - name: arrangement + purpose: The synthetic corpus arrangement, reconstructed from rendered audio, at its own tempo. + tempo: 150 + - name: arrangement-groove + purpose: The synthetic corpus arrangement at a tempo whose rows last unequal ticks. + tempo: 210 diff --git a/src/sampletones_tools/tracker_playback/config/settings.yaml b/src/sampletones_tools/tracker_playback/config/settings.yaml new file mode 100644 index 000000000..25a120a44 --- /dev/null +++ b/src/sampletones_tools/tracker_playback/config/settings.yaml @@ -0,0 +1 @@ +examples_per_difference: 6 diff --git a/src/sampletones_tools/tracker_playback/corpus/__init__.py b/src/sampletones_tools/tracker_playback/corpus/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_tools/tracker_playback/corpus/build.py b/src/sampletones_tools/tracker_playback/corpus/build.py new file mode 100644 index 000000000..dff434b01 --- /dev/null +++ b/src/sampletones_tools/tracker_playback/corpus/build.py @@ -0,0 +1,90 @@ +from dataclasses import dataclass +from typing import List + +from sampletones_core.project.project import Project +from sampletones_tools.corpus.build import build_project, build_synthetic_corpus +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 + + +def written_project( + spec: ProjectSpec, + corpus: CorpusSpec, +) -> CorpusProject: + """The project a spec writes out, holding fresh copies of the corpus voices it lists. + + Args: + spec: The project. + corpus: The corpus whose voices the project draws on. + + Returns: + CorpusProject: 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( + name=spec.name, + purpose=spec.purpose, + project=build_project( + voices, + spec.module, + spec.song, + ), + ) + + +def arrangement_project( + spec: ArrangementSpec, + arrangement: Project, +) -> CorpusProject: + """The synthetic corpus arrangement played at the tempo a spec names. + + Args: + spec: The tempo, the name and the purpose. + arrangement: The arrangement as the synthetic corpus builds it. + + Returns: + CorpusProject: The arrangement at that tempo. + """ + return CorpusProject( + name=spec.name, + purpose=spec.purpose, + project=at_tempo( + arrangement, + spec.tempo, + ), + ) + + +def comparison_corpus(corpus: CorpusSpec) -> List[CorpusProject]: + """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 + arrangement at each of its tempi. + """ + written = [written_project(spec, corpus) for spec in corpus.projects] + arrangement = build_synthetic_corpus().project + return written + [arrangement_project(spec, arrangement) for spec in corpus.arrangements] diff --git a/src/sampletones_tools/tracker_playback/corpus/spec.py b/src/sampletones_tools/tracker_playback/corpus/spec.py new file mode 100644 index 000000000..c1305a299 --- /dev/null +++ b/src/sampletones_tools/tracker_playback/corpus/spec.py @@ -0,0 +1,119 @@ +from typing import Annotated, Dict, List, Literal, Self, Union + +from pydantic import BaseModel, ConfigDict, Field + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.project.voices.envelopes import InstrumentEnvelopes +from sampletones_shared.utils.serialization import load_yaml_model +from sampletones_tools.corpus.module import ModuleConfig +from sampletones_tools.corpus.song import SongSpec +from sampletones_tools.tracker_playback.paths import CORPUS_PATH + +FrameValue = Union[bool, int] + + +class FrameRun(BaseModel): + """Frames one channel of a sample plays alike, one after another. + + Attributes: + count: How many frames the run lasts. + frame: The instruction each of those frames plays, in the fields the channel's instruction takes. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + count: int = Field(..., ge=1) + frame: Dict[str, FrameValue] + + +class SampleSpec(BaseModel): + """A voice with a recording behind it, its frames written out channel by channel. + + Attributes: + kind: Marks the voice as a sample. + channels: The frames each channel plays, as runs of alike frames. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + kind: Literal["sample"] + channels: Dict[ChannelName, List[FrameRun]] + + +class InstrumentSpec(BaseModel): + """A hand-written voice: envelopes every channel reads its own way. + + Attributes: + kind: Marks the voice as an instrument. + initial_pitch: The note a tonal channel measures the arpeggio against. + initial_period: The period the noise channel measures the arpeggio against. + envelopes: The per-tick values every channel reads. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + kind: Literal["instrument"] + initial_pitch: int + initial_period: int + envelopes: InstrumentEnvelopes + + +VoiceSpec = Annotated[Union[SampleSpec, InstrumentSpec], Field(discriminator="kind")] + + +class ProjectSpec(BaseModel): + """One project of the corpus: what it exercises, its settings, its voices and its song. + + Attributes: + name: The name its files are written under. + purpose: What the project exercises, in one sentence. + module: The title, the author and the playback settings. + voices: The corpus voices the project holds, in the order it lists them. + song: The order and the patterns. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + name: str + purpose: str + module: ModuleConfig + voices: List[str] + song: SongSpec + + +class ArrangementSpec(BaseModel): + """The synthetic corpus arrangement played at one tempo. + + Attributes: + name: The name its files are written under. + purpose: What playing the arrangement at this tempo exercises, in one sentence. + tempo: The tempo it is played at. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + name: str + purpose: str + tempo: int + + +class CorpusSpec(BaseModel): + """The projects a tracker playback check plays, and the voices they draw on. + + Attributes: + voices: Every voice a project may hold, by name. + projects: The projects written out here, each exercising one thing a song can do. + arrangements: The synthetic corpus arrangement, reconstructed from rendered audio, at each + tempo it is played at. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + voices: Dict[str, VoiceSpec] + projects: List[ProjectSpec] + arrangements: List[ArrangementSpec] + + @classmethod + def load(cls) -> Self: + """The corpus the package ships.""" + return load_yaml_model(CORPUS_PATH, cls) diff --git a/src/sampletones_tools/tracker_playback/corpus/voices.py b/src/sampletones_tools/tracker_playback/corpus/voices.py new file mode 100644 index 000000000..0b2763f5f --- /dev/null +++ b/src/sampletones_tools/tracker_playback/corpus/voices.py @@ -0,0 +1,81 @@ +from pathlib import Path +from typing import Dict, Final, List, Sequence, Tuple + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.exporters.maps import CHANNEL_TO_EXPORTER_MAP +from sampletones_core.instructions import InstructionUnion +from sampletones_core.project.voices.instrument import Instrument +from sampletones_core.project.voices.sample import Sample +from sampletones_core.project.voices.voice import VoiceUnion +from sampletones_tools.corpus.written import written_reconstruction +from sampletones_tools.tracker_playback.corpus.spec import ( + FrameRun, + InstrumentSpec, + SampleSpec, + VoiceSpec, +) + +WRITTEN_COEFFICIENT: Final[float] = 1.0 +WRITTEN_SCALE: Final[float] = 1.0 +NO_RECORDINGS: Final[Tuple[Path, ...]] = () + + +def channel_frames( + channel: ChannelName, + runs: Sequence[FrameRun], +) -> List[InstructionUnion]: + """The frames one channel of a sample plays, each run read as the instruction that channel takes. + + Args: + channel: The channel the frames play on. + runs: The runs of alike frames, in order. + + Returns: + List[InstructionUnion]: One instruction per frame. + + Raises: + ValidationError: If a run's frame is no instruction the channel takes. + """ + instruction_type = CHANNEL_TO_EXPORTER_MAP[channel].get_instruction_type() + frames: List[InstructionUnion] = [] + for run in runs: + frame = instruction_type.model_validate(run.frame) + frames.extend(frame for _ in range(run.count)) + + return frames + + +def build_voice( + name: str, + spec: VoiceSpec, +) -> VoiceUnion: + """The voice a spec describes: a sample playing its written frames, or a hand-written instrument. + + Args: + name: The voice's name. + spec: What the voice plays. + + Returns: + VoiceUnion: The voice. + """ + match spec: + case SampleSpec(): + instructions: Dict[ChannelName, List[InstructionUnion]] = { + channel: channel_frames(channel, runs) for channel, runs in spec.channels.items() + } + return Sample( + name=name, + reconstruction=written_reconstruction( + instructions, + coefficient=WRITTEN_COEFFICIENT, + scale=WRITTEN_SCALE, + audio_filepath=NO_RECORDINGS, + ), + ) + case InstrumentSpec(): + return Instrument( + name=name, + initial_pitch=spec.initial_pitch, + initial_period=spec.initial_period, + envelopes=spec.envelopes, + ) diff --git a/src/sampletones_tools/tracker_playback/outcome.py b/src/sampletones_tools/tracker_playback/outcome.py new file mode 100644 index 000000000..c6978ff3e --- /dev/null +++ b/src/sampletones_tools/tracker_playback/outcome.py @@ -0,0 +1,23 @@ +from dataclasses import dataclass +from typing import Optional + +from sampletones_core.exporters.truncation import EnvelopeTruncation +from sampletones_tools.tracker_playback.comparison import TraceComparison +from sampletones_tools.tracker_playback.corpus.build import CorpusProject + + +@dataclass(frozen=True) +class ProjectOutcome: + """How one project of the corpus 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. + truncation: The instruments a macro shortened, or ``None`` where every one fit. + """ + + project: CorpusProject + comparison: TraceComparison + skipped_rows: int + truncation: Optional[EnvelopeTruncation] diff --git a/src/sampletones_tools/tracker_playback/paths.py b/src/sampletones_tools/tracker_playback/paths.py new file mode 100644 index 000000000..aed46f3b4 --- /dev/null +++ b/src/sampletones_tools/tracker_playback/paths.py @@ -0,0 +1,16 @@ +from pathlib import Path +from typing import Final + +from sampletones_shared.paths.package import package_directory +from sampletones_shared.paths.user import USER_PATH_DOCUMENTS + +CONFIG_DIRECTORY: Final[Path] = package_directory("sampletones_tools.tracker_playback.config") +CORPUS_PATH: Final[Path] = CONFIG_DIRECTORY / "corpus.yaml" +SETTINGS_PATH: Final[Path] = CONFIG_DIRECTORY / "settings.yaml" +BITPHASE_SCRIPT_DIRECTORY: Final[Path] = package_directory( + "sampletones_tools.tracker_playback.targets.bitphase.script", +) +BITPHASE_TRACE_SCRIPT_PATH: Final[Path] = BITPHASE_SCRIPT_DIRECTORY / "trace.mts" +OUTPUT_ROOT: Final[Path] = USER_PATH_DOCUMENTS / "tracker-playback" +DOCUMENTS_DIRECTORY_NAME: Final[str] = "documents" +REPORT_FILENAME: Final[str] = "report.md" diff --git a/src/sampletones_tools/tracker_playback/report.py b/src/sampletones_tools/tracker_playback/report.py new file mode 100644 index 000000000..c56f260ea --- /dev/null +++ b/src/sampletones_tools/tracker_playback/report.py @@ -0,0 +1,269 @@ +from typing import Final, List, Sequence, Tuple + +from sampletones_core.constants.enums import ChannelName +from sampletones_shared.utils.tables import Table +from sampletones_tools.tracker_playback.comparison import ( + Divergence, + DivergentTick, + TimingDivergence, + TraceComparison, +) +from sampletones_tools.tracker_playback.outcome import ProjectOutcome +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" +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 " + "each writes to the sound chip. A channel that is silent on both sides counts as alike." +) +SUMMARY_COLUMNS: Final[Tuple[str, ...]] = ("Project", "Ticks in the app", "Ticks in the tracker", "Result") +DIVERGENCE_COLUMNS: Final[Tuple[str, ...]] = ( + "Channel", + "Differs in", + "Tick", + "Frame", + "Row", + "Application", + "Tracker", + "Ticks", +) +MATCHES: Final[str] = "matches" +DIFFERS_ONCE: Final[str] = "1 difference" +TIMING_ONLY: Final[str] = "the rows or the length differ" +DIFFERS: Final[str] = "{count} differences" +LENGTHS_DIFFER: Final[str] = "The application plays {application} ticks and the tracker plays {engine}." +TIMING_DIFFERS: Final[str] = ( + "The rows part at tick {tick}: the application is at frame {frame}, row {row}, " + "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}." +SHORTENED: Final[str] = ( + "Instruments the export shortened to {frames} of their {source_frames} frames, " + "the most the format holds: {instruments}." +) +SILENT: Final[str] = "silent" +PULSE_SOUND: Final[str] = "timer {period}, volume {volume}, duty {timbre}" +TRIANGLE_SOUND: Final[str] = "timer {period}" +NOISE_SOUND: Final[str] = "period {period}, volume {volume}, {mode}" +COUNTED_DOWN: Final[str] = ", counted down" +SHORT_MODE: Final[str] = "short" +LONG_MODE: Final[str] = "long" +FIELD_SEPARATOR: Final[str] = ", " +CONTINUED: Final[str] = "" + + +def describe_sound( + channel: ChannelName, + sound: ChannelSound, +) -> str: + """One channel's sound on a tick, in the registers a reader looks for on that channel. + + A sound the chip's own envelope or counters move on from its registers closes with + ``COUNTED_DOWN``. + + Args: + channel: The channel sounding. + sound: What it sounds. + + Returns: + str: The sound, or that the channel is silent. + """ + if not sound.audible: + return SILENT + + closing = "" if sound.held else COUNTED_DOWN + return registers_text(channel, sound) + closing + + +def registers_text( + channel: ChannelName, + sound: ChannelSound, +) -> str: + """The registers a reader looks for on one channel, as a sounding tick holds them.""" + match channel: + case ChannelName.PULSE1 | ChannelName.PULSE2: + return PULSE_SOUND.format( + period=sound.period, + volume=sound.volume, + timbre=sound.timbre, + ) + case ChannelName.TRIANGLE: + return TRIANGLE_SOUND.format(period=sound.period) + case ChannelName.NOISE: + mode = SHORT_MODE if sound.timbre else LONG_MODE + return NOISE_SOUND.format( + period=sound.period, + volume=sound.volume, + mode=mode, + ) + + +def result_label(comparison: TraceComparison) -> str: + """The verdict a project's line in the summary carries: a match, or how many ways it differs.""" + if comparison.matches: + return MATCHES + + count = len(comparison.divergences) + match count: + case 0: + return TIMING_ONLY + case 1: + return DIFFERS_ONCE + case _: + return DIFFERS.format(count=count) + + +def divergence_table(divergences: Sequence[Divergence]) -> Table: + """The table listing every way a project's channels sound differently, a line per example. + + A divergence's first line names the channel, the fields and how many ticks it strikes, and the + lines under it carry its further examples alone. + """ + return Table( + columns=DIVERGENCE_COLUMNS, + rows=tuple( + divergence_row(divergence, example, opening=index == 0) + for divergence in divergences + for index, example in enumerate(divergence.examples) + ), + ) + + +def divergence_row( + divergence: Divergence, + example: DivergentTick, + *, + opening: bool, +) -> Tuple[str, ...]: + """One line of the divergence table: an example, under the divergence it belongs to where it opens it.""" + return ( + divergence.channel.value if opening else CONTINUED, + FIELD_SEPARATOR.join(field.value for field in divergence.fields) if opening else CONTINUED, + str(example.tick), + str(example.position.frame), + str(example.position.row), + describe_sound(divergence.channel, example.application), + describe_sound(divergence.channel, example.engine), + str(divergence.ticks) if opening else CONTINUED, + ) + + +def timing_line(timing: TimingDivergence) -> str: + """Where the two players first place a tick at different rows.""" + return TIMING_DIFFERS.format( + tick=timing.tick, + frame=timing.position.frame, + row=timing.position.row, + engine_frame=timing.engine_position.frame, + engine_row=timing.engine_position.row, + ) + + +def export_notes(outcome: ProjectOutcome) -> List[str]: + """What the export itself reported leaving out, which accounts for a difference it expects. + + Args: + outcome: How the project fared. + + 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)) + + truncation = outcome.truncation + if truncation is not None: + notes.append( + SHORTENED.format( + instruments=truncation.instruments, + frames=truncation.frames, + source_frames=truncation.source_frames, + ) + ) + + return notes + + +def project_section(outcome: ProjectOutcome) -> List[str]: + """The report's section on one project: what it exercises, what the export left out, and every difference. + + Args: + outcome: How the project fared. + + Returns: + List[str]: The section's lines. + """ + comparison = outcome.comparison + lines = [f"## {outcome.project.name}", "", outcome.project.purpose, ""] + lines.extend(line for note in export_notes(outcome) for line in (note, "")) + if comparison.application_ticks != comparison.engine_ticks: + lines.extend( + ( + LENGTHS_DIFFER.format( + application=comparison.application_ticks, + engine=comparison.engine_ticks, + ), + "", + ) + ) + + if comparison.timing is not None: + lines.extend((timing_line(comparison.timing), "")) + + if comparison.divergences: + lines.extend(divergence_table(comparison.divergences).markdown_lines()) + lines.append("") + elif comparison.matches: + lines.extend((ALL_ALIKE, "")) + + return lines + + +def summary_table(outcomes: Sequence[ProjectOutcome]) -> Table: + """The table naming every project with its tick counts and its verdict.""" + return Table( + columns=SUMMARY_COLUMNS, + rows=tuple( + ( + outcome.project.name, + str(outcome.comparison.application_ticks), + str(outcome.comparison.engine_ticks), + result_label(outcome.comparison), + ) + for outcome in outcomes + ), + ) + + +def report_text( + target: PlaybackTarget, + outcomes: Sequence[ProjectOutcome], +) -> str: + """The whole report: the summary first, then a section per project. + + Args: + target: The tracker that played the projects. + outcomes: How each project fared, in the order they were played. + + Returns: + str: The report as Markdown. + """ + lines = [ + TITLE.format(tracker=target.title), + "", + INTRODUCTION.format( + tracker=target.title, + player=target.player, + ), + "", + ] + lines.extend(summary_table(outcomes).markdown_lines()) + lines.append("") + for outcome in outcomes: + lines.extend(project_section(outcome)) + + return "\n".join(lines) diff --git a/src/sampletones_tools/tracker_playback/session.py b/src/sampletones_tools/tracker_playback/session.py new file mode 100644 index 000000000..9ec8a3b84 --- /dev/null +++ b/src/sampletones_tools/tracker_playback/session.py @@ -0,0 +1,118 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Sequence, Tuple + +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.report import report_text +from sampletones_tools.tracker_playback.settings import PlaybackSettings +from sampletones_tools.tracker_playback.targets.protocol import PlaybackTarget +from sampletones_tools.tracker_playback.trace.application import application_trace + + +@dataclass(frozen=True) +class PlaybackRun: + """What a check plays the corpus through, where it writes, and how it reports. + + Attributes: + target: The tracker the projects are exported to and played by. + output: The directory the exported files, what their playing wrote and the report go into. + settings: How the report shows what it finds. + """ + + target: PlaybackTarget + output: Path + settings: PlaybackSettings + + +@dataclass(frozen=True) +class PlaybackOutcome: + """What a check found, and the report stating it. + + Attributes: + outcomes: How each project fared, in the order they were played. + report: The report written. + """ + + outcomes: Tuple[ProjectOutcome, ...] + report: Path + + +def default_output() -> Path: + """The directory a check given no output writes into: a timestamped one under Documents.""" + return stamped_run_directory(OUTPUT_ROOT) + + +def check_project( + corpus_project: CorpusProject, + run: PlaybackRun, +) -> ProjectOutcome: + """Plays one project through the target and holds what it played against the application. + + Args: + corpus_project: The project to check. + run: What plays it, where the files go, and how differences are shown. + + Returns: + ProjectOutcome: How the tracker played the project, and what the export reported leaving out. + + Raises: + PlaybackError: If the tracker fails to play the project. + """ + playback = run.target.play( + corpus_project.project, + run.output / DOCUMENTS_DIRECTORY_NAME, + corpus_project.name, + ) + return ProjectOutcome( + project=corpus_project, + comparison=compare_traces( + application_trace(corpus_project.project), + playback.trace, + examples=run.settings.examples_per_difference, + ), + skipped_rows=playback.skipped_rows, + truncation=playback.truncation, + ) + + +def check_corpus( + projects: Sequence[CorpusProject], + run: PlaybackRun, +) -> PlaybackOutcome: + """Checks every project of a corpus 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. + + Args: + projects: The projects to check. + run: What plays them, where the files go, and how differences are shown. + + Returns: + PlaybackOutcome: How each project fared, and the report. + + Raises: + 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) + report = run.output / REPORT_FILENAME + report.write_text( + report_text( + run.target, + outcomes, + ), + encoding="utf-8", + ) + return PlaybackOutcome( + outcomes=outcomes, + report=report, + ) diff --git a/src/sampletones_tools/tracker_playback/settings.py b/src/sampletones_tools/tracker_playback/settings.py new file mode 100644 index 000000000..0b91a55a8 --- /dev/null +++ b/src/sampletones_tools/tracker_playback/settings.py @@ -0,0 +1,24 @@ +from typing import Self + +from pydantic import BaseModel, ConfigDict, Field + +from sampletones_shared.utils.serialization import load_yaml_model +from sampletones_tools.tracker_playback.paths import SETTINGS_PATH + + +class PlaybackSettings(BaseModel): + """How a tracker playback check reports what it finds. + + Attributes: + examples_per_difference: How many ticks a difference shows as examples at most: the first it + strikes, then the later rows where both sides sound new values. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + examples_per_difference: int = Field(..., ge=1) + + @classmethod + def load(cls) -> Self: + """The settings the package ships.""" + return load_yaml_model(SETTINGS_PATH, cls) diff --git a/src/sampletones_tools/tracker_playback/targets/__init__.py b/src/sampletones_tools/tracker_playback/targets/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_tools/tracker_playback/targets/bitphase/__init__.py b/src/sampletones_tools/tracker_playback/targets/bitphase/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_tools/tracker_playback/targets/bitphase/engine.py b/src/sampletones_tools/tracker_playback/targets/bitphase/engine.py new file mode 100644 index 000000000..28c8913b9 --- /dev/null +++ b/src/sampletones_tools/tracker_playback/targets/bitphase/engine.py @@ -0,0 +1,172 @@ +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Final, List, Self, 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.paths import BITPHASE_TRACE_SCRIPT_PATH +from sampletones_tools.tracker_playback.targets.bitphase.trace import read_engine_trace +from sampletones_tools.tracker_playback.targets.protocol import PlaybackError +from sampletones_tools.tracker_playback.trace.sound import SongTrace + +NODE: Final[str] = "node" +NODE_PURPOSE: Final[str] = "a Bitphase checkout's own engine plays the exported documents" +TSX_CLI: Final[Path] = Path("node_modules") / "tsx" / "dist" / "cli.mjs" +CHECKOUT_FILES: Final[Tuple[Path, ...]] = ( + Path("cli") / "btp-loader.ts", + Path("cli") / "resource-loader-node.ts", + Path("src") / "lib" / "chips" / "registry-core.ts", + Path("public") / "nes" / "nes-audio-driver.js", + Path("public") / "nes" / "nes-apu-engine.js", + Path("public") / "nes" / "nes_apu.wasm", + TSX_CLI, +) + +INSTALL_HINTS: Final[Dict[System, str]] = { + System.LINUX: "sudo apt install nodejs", + System.MACOS: "brew install node", + System.WINDOWS: "install Node.js from https://nodejs.org", +} + + +class EngineError(PlaybackError): + """Bitphase's engine could not play a document: node is absent, the checkout lacks a file, or the run failed.""" + + +@dataclass(frozen=True) +class BitphaseCheckout: + """A Bitphase source checkout whose engine plays the documents, its packages installed. + + Attributes: + root: The checkout's top directory. + """ + + root: Path + + @classmethod + def located(cls, root: Path) -> Self: + """The checkout at ``root``, once every file the trace loads from it is found there. + + Args: + root: The checkout's top directory. + + Returns: + Self: The checkout. + + Raises: + EngineError: If a file the trace loads is missing, naming each one. + """ + missing = [str(relative) for relative in CHECKOUT_FILES if not (root / relative).is_file()] + if missing: + raise EngineError( + f"{root} is no Bitphase checkout with its packages installed; it lacks {', '.join(missing)}. " + "Clone https://github.com/paator/bitphase and run pnpm install there." + ) + + return cls(root=root) + + +@dataclass(frozen=True) +class BitphaseEngine: + """Bitphase's own engine, run through node over one checkout. + + Attributes: + node: The node program. + checkout: The checkout whose modules play the documents. + """ + + node: Path + checkout: BitphaseCheckout + + @classmethod + def located(cls, root: Path) -> Self: + """The engine of the checkout at ``root``, played by the node this system has. + + Args: + root: The checkout's top directory. + + Returns: + Self: The engine. + + Raises: + EngineError: If node is absent, naming how this system installs it, or the checkout lacks a + file the trace loads. + """ + node = locate_program(NODE) + if node is None: + raise EngineError( + missing_program_message( + NODE, + NODE_PURPOSE, + INSTALL_HINTS, + ) + ) + + return cls( + node=node, + checkout=BitphaseCheckout.located(root), + ) + + def command( + self, + document: Path, + output: Path, + ) -> List[str]: + """The command that plays ``document`` and writes its trace to ``output``. + + The checkout's own tsx runs the trace script, so the script loads the checkout's TypeScript + modules the way its own command-line tools do. The script runs inside the checkout, so the + document and the trace are named by absolute paths. + + Args: + document: The `.btp` document to play. + output: Where the trace is written. + + Returns: + List[str]: The program and its arguments. + """ + return [ + str(self.node), + str(self.checkout.root / TSX_CLI), + str(BITPHASE_TRACE_SCRIPT_PATH), + str(self.checkout.root), + str(document.resolve()), + str(output.resolve()), + ] + + def trace( + self, + document: Path, + output: Path, + ) -> SongTrace: + """Plays a document through the engine and reads what every channel sounds on every tick. + + The trace holds every write the engine made to the chip's registers, and it is written to + ``output`` and kept there, so a run can be read again by hand. + + Args: + document: The `.btp` document to play. + output: Where the trace is written. + + Returns: + SongTrace: One pass through the document's song. + + Raises: + EngineError: If the engine fails to play the document. + """ + try: + subprocess.run( + self.command(document, output), + cwd=self.checkout.root, + capture_output=True, + text=True, + check=True, + ) + except subprocess.CalledProcessError as error: + raise EngineError(f"Bitphase failed to play {document}:\n{error.stderr}") from error + + return read_engine_trace(output.read_text(encoding="utf-8")) diff --git a/src/sampletones_tools/tracker_playback/targets/bitphase/script/__init__.py b/src/sampletones_tools/tracker_playback/targets/bitphase/script/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_tools/tracker_playback/targets/bitphase/script/trace.mts b/src/sampletones_tools/tracker_playback/targets/bitphase/script/trace.mts new file mode 100644 index 000000000..2f412074e --- /dev/null +++ b/src/sampletones_tools/tracker_playback/targets/bitphase/script/trace.mts @@ -0,0 +1,167 @@ +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +type Timeline = { currentPatternOrderIndex: number; currentRow: number }; + +type TrackerState = { timeline: Timeline }; + +type UnitWrite = { unit: string; address: number; value: number }; + +type TracedTick = { frame: number; row: number; writes: UnitWrite[] }; + +type Write = (pointer: number, address: number, value: number) => unknown; + +type Engine = { + applyRegisterState(registers: unknown): void; + process(sampleRate: number): unknown; +}; + +type Constructor = new (...args: any[]) => T; + +type Module = Record; + +const USAGE = "Usage: trace.mts "; +const USAGE_STATUS = 2; +const PUBLIC_DIRECTORY = "public"; +const NES_CHIP = "nes"; +const SONG_INDEX = 0; +const ONE_PASS = 1; +const SILENT_OUTPUT = { left: 0, right: 0 }; +const STATE_MODULE = "nes/nes-state.js"; +const DRIVER_MODULE = "nes/nes-audio-driver.js"; +const ENGINE_MODULE = "nes/nes-apu-engine.js"; +const UNIT_WRITES: Record = { + apu: "nes_apu_Write", + dmc: "nes_dmc_Write" +}; + +class TickRecorder { + readonly ticks: TracedTick[] = []; + private pending: UnitWrite[] = []; + private state: TrackerState | null = null; + private driverPassed = false; + + watchState(state: TrackerState): void { + this.state = state; + } + + markDriverPass(): void { + this.driverPassed = true; + } + + write(unit: string, address: number, value: number): void { + this.pending.push({ unit, address, value }); + } + + // The engine writes the chip as it resets and once more before the first tick, so a tick is the + // apply that follows a pass of the driver over the instruments, carrying every write since the + // tick before. + record(): void { + if (!this.driverPassed || this.state === null) { + return; + } + this.driverPassed = false; + this.ticks.push({ + frame: this.state.timeline.currentPatternOrderIndex, + row: this.state.timeline.currentRow, + writes: this.pending + }); + this.pending = []; + } +} + +// The emulator splits the APU into two units, and the engine writes each through an export of its +// own on the module it is created with. The exports object is frozen, so the engine is handed a copy. +function recordingWasm(wasm: Module, recorder: TickRecorder): Module { + const recording: Module = { ...wasm }; + for (const [unit, name] of Object.entries(UNIT_WRITES)) { + const write = wasm[name] as Write; + recording[name] = (pointer: number, address: number, value: number): unknown => { + recorder.write(unit, address, value); + return write(pointer, address, value); + }; + } + return recording; +} + +function recordingModule(url: string, module: Module, recorder: TickRecorder): Module { + switch (url) { + case STATE_MODULE: { + const State = module.default as Constructor; + class WatchedState extends State { + constructor(...args: any[]) { + super(...args); + recorder.watchState(this); + } + } + return { ...module, default: WatchedState }; + } + case DRIVER_MODULE: { + const Driver = module.default as Constructor<{ + processInstruments(state: unknown, registers: unknown): void; + }>; + class WatchedDriver extends Driver { + processInstruments(state: unknown, registers: unknown): void { + super.processInstruments(state, registers); + recorder.markDriverPass(); + } + } + return { ...module, default: WatchedDriver }; + } + case ENGINE_MODULE: + return { + ...module, + createNesApuEngine(wasm: Module) { + const made = module.createNesApuEngine(recordingWasm(wasm, recorder)); + const engine = made.engine as Engine; + const apply = engine.applyRegisterState.bind(engine); + engine.applyRegisterState = (registers: unknown) => { + apply(registers); + recorder.record(); + }; + engine.process = () => SILENT_OUTPUT; + return made; + } + }; + default: + return module; + } +} + +async function main(): Promise { + const [root, documentPath, tracePath] = process.argv.slice(2); + if (root === undefined || documentPath === undefined || tracePath === undefined) { + console.error(USAGE); + process.exit(USAGE_STATUS); + } + + const upstream = (relative: string): Promise => + import(pathToFileURL(path.join(root, relative)).href); + const { loadBtpFromFile } = await upstream("cli/btp-loader.ts"); + const { FileSystemResourceLoader } = await upstream("cli/resource-loader-node.ts"); + const { ensureCoreRegistry, getChipByType } = await upstream("src/lib/chips/registry-core.ts"); + + await ensureCoreRegistry(); + const project = loadBtpFromFile(path.resolve(documentPath)); + const chip = getChipByType(NES_CHIP); + const files = new FileSystemResourceLoader(path.join(root, PUBLIC_DIRECTORY)); + const recorder = new TickRecorder(); + const loader = { + loadWasm: (url: string): Promise => files.loadWasm(url), + loadModule: async (url: string): Promise => + recordingModule(url, await files.loadModule(url), recorder) + }; + + const renderer = chip.createRenderer(loader, { + chipType: chip.type, + audioSlotKind: chip.audioSlotKind + }); + await renderer.render(project, SONG_INDEX, undefined, { + separateChannels: false, + loopCount: ONE_PASS + }); + fs.writeFileSync(tracePath, JSON.stringify({ ticks: recorder.ticks })); +} + +await main(); diff --git a/src/sampletones_tools/tracker_playback/targets/bitphase/target.py b/src/sampletones_tools/tracker_playback/targets/bitphase/target.py new file mode 100644 index 000000000..745517416 --- /dev/null +++ b/src/sampletones_tools/tracker_playback/targets/bitphase/target.py @@ -0,0 +1,87 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Final, Self + +from sampletones_core.formats.bitphase.btp import write_btp +from sampletones_core.formats.bitphase.builder import build_bitphase +from sampletones_core.project.project import Project +from sampletones_shared.paths.extensions import EXT_FILE_BITPHASE, EXT_FILE_JSON +from sampletones_tools.tracker_playback.targets.bitphase.engine import BitphaseEngine +from sampletones_tools.tracker_playback.targets.protocol import TargetPlayback + +TITLE: Final[str] = "Bitphase" +PLAYER: Final[str] = "the engine of the Bitphase checkout at `{root}`" + + +@dataclass(frozen=True) +class BitphaseTarget: + """Bitphase as a playback target: a project exported to a `.btp` document and played by Bitphase's engine. + + Attributes: + engine: The engine of the checkout that plays the documents. + """ + + engine: BitphaseEngine + + @classmethod + def located(cls, root: Path) -> Self: + """The target playing documents with the engine of the checkout at ``root``. + + Args: + root: The Bitphase checkout's top directory. + + Returns: + Self: The target. + + Raises: + EngineError: If node is absent, or the checkout lacks a file the trace loads. + """ + return cls(engine=BitphaseEngine.located(root)) + + @property + def title(self) -> str: + """The tracker's name, as the report prints it.""" + return TITLE + + @property + def player(self) -> str: + """The checkout whose engine plays the documents, as the report introduces it.""" + return PLAYER.format(root=self.engine.checkout.root) + + def play( + self, + project: Project, + directory: Path, + name: str, + ) -> TargetPlayback: + """Exports a project to a `.btp` document, plays it with Bitphase's engine and reads every tick. + + The document is built by the exporter the application's Bitphase export runs, and the trace the + engine writes is kept beside it. + + Args: + project: The project to export and play. + directory: Where the document and its trace are written. + name: The stem both files are written under. + + Returns: + TargetPlayback: The document, what Bitphase played of it, and what the export left out. + + Raises: + EngineError: If Bitphase fails to play the document. + """ + built = build_bitphase(project) + document = directory / f"{name}{EXT_FILE_BITPHASE}" + write_btp( + document, + built.document, + ) + return TargetPlayback( + document=document, + trace=self.engine.trace( + document, + directory / f"{name}{EXT_FILE_JSON}", + ), + skipped_rows=len(built.skipped_rows), + truncation=built.truncation, + ) diff --git a/src/sampletones_tools/tracker_playback/targets/bitphase/trace.py b/src/sampletones_tools/tracker_playback/targets/bitphase/trace.py new file mode 100644 index 000000000..6793fcaaa --- /dev/null +++ b/src/sampletones_tools/tracker_playback/targets/bitphase/trace.py @@ -0,0 +1,137 @@ +from enum import StrEnum +from typing import Dict, Final, List, Tuple + +from pydantic import BaseModel, ConfigDict, Field + +from sampletones_core.constants.enums import ChannelName +from sampletones_player.specification.registers import ( + APU_FRAME_COUNTER, + APU_STATUS, + FIRST_CHANNEL_REGISTER, + MAX_REGISTER_VALUE, +) +from sampletones_tools.player.trace.write import RegisterWrite +from sampletones_tools.tracker_playback.trace.decode import ( + CHANNEL_STATUS_BITS, + DMC_STATUS_BIT, + song_trace, +) +from sampletones_tools.tracker_playback.trace.registers import ChipRegisters +from sampletones_tools.tracker_playback.trace.sound import SongTrace, TickPosition + + +class EmulatorUnit(StrEnum): + """The two units Bitphase's emulator splits the APU into, each written through an export of its own. + + `apu` plays the two pulse channels and `dmc` plays the triangle, the noise and the DMC. Each + keeps its own copy of the status register and reads only its own channels' bits of it. + """ + + APU = "apu" + DMC = "dmc" + + +UNIT_STATUS_BITS: Final[Dict[EmulatorUnit, int]] = { + EmulatorUnit.APU: CHANNEL_STATUS_BITS[ChannelName.PULSE1] | CHANNEL_STATUS_BITS[ChannelName.PULSE2], + EmulatorUnit.DMC: ( + CHANNEL_STATUS_BITS[ChannelName.TRIANGLE] | CHANNEL_STATUS_BITS[ChannelName.NOISE] | DMC_STATUS_BIT + ), +} + + +class UnitWrite(BaseModel): + """One write Bitphase's engine makes to a unit of its emulated APU. + + Attributes: + unit: The unit the write reaches. + address: The register written. + value: The byte written. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + unit: EmulatorUnit + address: int = Field(..., ge=FIRST_CHANNEL_REGISTER, le=APU_FRAME_COUNTER) + value: int = Field(..., ge=0, le=MAX_REGISTER_VALUE) + + def chip_write(self, status: int) -> RegisterWrite: + """The write as one whole APU takes it, given the status register standing before it. + + A status write reaches the bits of the channels its unit plays and leaves the other unit's + bits as they stand, which is what the two units enable between them. + + Args: + status: The status register before the write. + + Returns: + RegisterWrite: The write the whole chip takes. + """ + if self.address != APU_STATUS: + return RegisterWrite(self.address, self.value) + + bits = UNIT_STATUS_BITS[self.unit] + return RegisterWrite(APU_STATUS, (status & ~bits) | (self.value & bits)) + + +class EngineTick(BaseModel): + """Every write Bitphase's engine made to the chip since the tick before, and where in the song the tick falls. + + The first tick also carries the writes the engine makes as it resets the chip and before it + plays. + + Attributes: + frame: The order position being played. + row: The row of that position's pattern. + writes: The writes, in the order the engine made them. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + frame: int + row: int + writes: Tuple[UnitWrite, ...] + + +class EngineTrace(BaseModel): + """Every tick of one pass through a document, as the trace script writes it.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + ticks: Tuple[EngineTick, ...] + + @property + def positions(self) -> Tuple[TickPosition, ...]: + """Where each tick falls.""" + return tuple(TickPosition(frame=tick.frame, row=tick.row) for tick in self.ticks) + + def registers(self) -> Tuple[ChipRegisters, ...]: + """The chip's registers on every tick, each tick's writes laid over what the tick before left. + + Returns: + Tuple[ChipRegisters, ...]: The registers on each tick, one per tick. + """ + registers = ChipRegisters.power_up() + per_tick: List[ChipRegisters] = [] + for tick in self.ticks: + for write in tick.writes: + registers = registers.written((write.chip_write(registers.value(APU_STATUS)),)) + + per_tick.append(registers) + + return tuple(per_tick) + + +def read_engine_trace(text: str) -> SongTrace: + """What a trace script's output says every channel plays, read out of the registers the engine wrote. + + Args: + text: The JSON the trace script wrote. + + Returns: + SongTrace: One pass through the song. + + Raises: + ValidationError: If the text is no trace the script writes. + """ + trace = EngineTrace.model_validate_json(text) + return song_trace(trace.positions, trace.registers()) diff --git a/src/sampletones_tools/tracker_playback/targets/protocol.py b/src/sampletones_tools/tracker_playback/targets/protocol.py new file mode 100644 index 000000000..ca61f15f5 --- /dev/null +++ b/src/sampletones_tools/tracker_playback/targets/protocol.py @@ -0,0 +1,66 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Optional, Protocol + +from sampletones_core.exporters.truncation import EnvelopeTruncation +from sampletones_core.project.project import Project +from sampletones_shared.exceptions import SampleToNESError +from sampletones_tools.tracker_playback.trace.sound import SongTrace + + +class PlaybackError(SampleToNESError): + """A target could not play a project: the tracker or what runs it is missing, or the run failed.""" + + +@dataclass(frozen=True) +class TargetPlayback: + """What a target made of one project, and what every channel sounded when the tracker played it. + + 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. + truncation: The instruments the format's value limit shortened, or ``None`` where every one fit. + """ + + document: Path + trace: SongTrace + skipped_rows: int + truncation: Optional[EnvelopeTruncation] + + +class PlaybackTarget(Protocol): + """A tracker a project is exported to and played by, so what it plays can be held against the app. + + Each tracker is one implementation. It exports the project with the application's own exporter, + plays the file with the tracker's own playback code, and reads what every channel sounds on every + tick into a ``SongTrace``, whatever produced those ticks. + """ + + @property + def title(self) -> str: + """The tracker's name, as the report prints it.""" + + @property + def player(self) -> str: + """What plays the exported files, as the report introduces it.""" + + def play( + self, + project: Project, + directory: Path, + name: str, + ) -> TargetPlayback: + """Exports a project, plays the file with the tracker, and reads every tick of every channel. + + Args: + project: The project to export and play. + directory: Where the exported file and whatever its playing writes are kept. + name: The stem every file of this project is written under. + + Returns: + TargetPlayback: The file exported, what it played, and what the export left out. + + Raises: + PlaybackError: If the tracker fails to play the file. + """ diff --git a/src/sampletones_tools/tracker_playback/trace/__init__.py b/src/sampletones_tools/tracker_playback/trace/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_tools/tracker_playback/trace/application.py b/src/sampletones_tools/tracker_playback/trace/application.py new file mode 100644 index 000000000..e3ebcaa88 --- /dev/null +++ b/src/sampletones_tools/tracker_playback/trace/application.py @@ -0,0 +1,90 @@ +from typing import List, Tuple + +from sampletones_core.performance import song_instructions +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_player.builder import streams_from_instructions +from sampletones_player.registers.streams import ChannelStreams +from sampletones_tools.player.trace.trace import channel_writes, setup_writes +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 + + +def application_trace(project: Project) -> SongTrace: + """What the application plays of a project, tick by tick, read out of the registers the console holds. + + The song is walked once into the instructions each channel sounds, the same walk the sequencer + and the NES player read, and those instructions become register values through the player's own + encoding. The registers the driver writes are then read the way a tracker's are, so both sides + of a comparison come out of the chip's registers by one rule. + + Args: + project: The project whose song is played. + + Returns: + SongTrace: One pass through the song, the order played once. + """ + timing = SongTiming.from_project(project) + streams = streams_from_instructions( + song_instructions(project), + get_timer_table(tuning_from_project(project)), + ) + return song_trace( + song_positions( + timing.groove(), + project.song.order_length(), + ), + driver_registers( + streams, + timing.frame_tick(project.song.order_length()), + ), + ) + + +def driver_registers( + streams: ChannelStreams, + ticks: int, +) -> Tuple[ChipRegisters, ...]: + """The console's registers on every tick of a song as the NES player's driver leaves them. + + The driver's init routine sets the console up, and every tick writes each channel's registers + over what the tick before left. A channel past the end of its stream holds its final values. + + Args: + streams: The register values each channel writes on each tick. + ticks: The ticks the song lasts. + + Returns: + Tuple[ChipRegisters, ...]: The registers on each tick, one per tick. + """ + registers = ChipRegisters.power_up().written(setup_writes()) + per_tick: List[ChipRegisters] = [] + for tick in range(ticks): + registers = registers.written(channel_writes(streams.at(tick))) + per_tick.append(registers) + + return tuple(per_tick) + + +def song_positions( + groove: Groove, + frames: int, +) -> Tuple[TickPosition, ...]: + """Where each tick of a song falls, every frame lasting one groove. + + Args: + groove: The ticks each row of a pattern lasts. + frames: The order frames the song plays. + + Returns: + Tuple[TickPosition, ...]: One position per tick, in order. + """ + positions: List[TickPosition] = [] + for frame in range(frames): + for row, row_ticks in enumerate(groove.ticks): + positions.extend(TickPosition(frame=frame, row=row) for _ in range(row_ticks)) + + return tuple(positions) diff --git a/src/sampletones_tools/tracker_playback/trace/decode.py b/src/sampletones_tools/tracker_playback/trace/decode.py new file mode 100644 index 000000000..fd05dde93 --- /dev/null +++ b/src/sampletones_tools/tracker_playback/trace/decode.py @@ -0,0 +1,219 @@ +from typing import Dict, Final, NamedTuple, Sequence, Tuple + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.general import MAX_PERIOD, MAX_VOLUME, SILENT_VOLUME +from sampletones_player.specification.registers import ( + APU_STATUS, + CONSTANT_VOLUME, + DUTY_CYCLE_SHIFT, + MAX_TIMER_HIGH, + NOISE_CONTROL, + NOISE_MODE_SHIFT, + NOISE_PERIOD, + PULSE1_CONTROL, + PULSE1_TIMER_HIGH, + PULSE1_TIMER_LOW, + PULSE2_CONTROL, + PULSE2_TIMER_HIGH, + PULSE2_TIMER_LOW, + SUSTAINED_LEVEL, + TIMER_HIGH_SHIFT, + TRIANGLE_COUNTER_CONTROL, + TRIANGLE_LINEAR_COUNTER, + TRIANGLE_TIMER_HIGH, + TRIANGLE_TIMER_LOW, +) +from sampletones_tools.tracker_playback.trace.registers import ChipRegisters +from sampletones_tools.tracker_playback.trace.sound import ( + ABSENT_REGISTER, + ChannelSound, + SongTrace, + TickPosition, +) + +CHANNEL_STATUS_BITS: Final[Dict[ChannelName, int]] = { + ChannelName.PULSE1: 0x01, + ChannelName.PULSE2: 0x02, + ChannelName.TRIANGLE: 0x04, + ChannelName.NOISE: 0x08, +} +DMC_STATUS_BIT: Final[int] = 0x10 +LINEAR_COUNTER_RELOAD: Final[int] = 0x7F + + +class ToneAddresses(NamedTuple): + """The registers a pulse or triangle channel keeps its control byte and its timer in. + + Attributes: + control: The byte carrying the level and the duty cycle on a pulse channel, and the linear + counter on the triangle. + timer_low: The timer's low byte. + timer_high: The byte carrying the timer's top three bits under the length index. + """ + + control: int + timer_low: int + timer_high: int + + +TONE_ADDRESSES: Final[Dict[ChannelName, ToneAddresses]] = { + ChannelName.PULSE1: ToneAddresses( + control=PULSE1_CONTROL, + timer_low=PULSE1_TIMER_LOW, + timer_high=PULSE1_TIMER_HIGH, + ), + ChannelName.PULSE2: ToneAddresses( + control=PULSE2_CONTROL, + timer_low=PULSE2_TIMER_LOW, + timer_high=PULSE2_TIMER_HIGH, + ), + ChannelName.TRIANGLE: ToneAddresses( + control=TRIANGLE_LINEAR_COUNTER, + timer_low=TRIANGLE_TIMER_LOW, + timer_high=TRIANGLE_TIMER_HIGH, + ), +} + + +def song_trace( + positions: Tuple[TickPosition, ...], + registers: Sequence[ChipRegisters], +) -> SongTrace: + """What every channel plays on every tick of a song, read out of the registers the chip holds on each. + + Args: + positions: Where each tick falls. + registers: The chip's registers on each tick, one per position. + + Returns: + SongTrace: Each channel's sound on every tick. + + Raises: + ValueError: If the registers cover a different number of ticks than the positions. + """ + if len(registers) != len(positions): + raise ValueError(f"{len(positions)} ticks have {len(registers)} register states") + + return SongTrace( + positions=positions, + channels={ + channel: tuple(channel_sound(channel, tick_registers) for tick_registers in registers) + for channel in ChannelName.items() + }, + ) + + +def channel_sound( + channel: ChannelName, + registers: ChipRegisters, +) -> ChannelSound: + """What the chip plays of one channel from the registers it holds. + + Args: + channel: The channel read. + registers: The chip's registers. + + Returns: + ChannelSound: The channel's sound. + """ + match channel: + case ChannelName.PULSE1 | ChannelName.PULSE2: + return pulse_sound(channel, registers) + case ChannelName.TRIANGLE: + return triangle_sound(registers) + case ChannelName.NOISE: + return noise_sound(registers) + + +def pulse_sound( + channel: ChannelName, + registers: ChipRegisters, +) -> ChannelSound: + """A pulse channel's sound: the duty cycle and the level ride its control byte, the timer its two timer bytes. + + Args: + channel: The pulse channel read. + registers: The chip's registers. + + Returns: + ChannelSound: The channel's sound. + """ + addresses = TONE_ADDRESSES[channel] + control = registers.value(addresses.control) + return ChannelSound( + audible=is_enabled(channel, registers) and level_sounds(control), + period=timer(addresses, registers), + volume=control & MAX_VOLUME, + held=level_held(control), + timbre=control >> DUTY_CYCLE_SHIFT, + ) + + +def triangle_sound(registers: ChipRegisters) -> ChannelSound: + """The triangle's sound: the linear counter's reload says whether it sounds, the timer at what pitch. + + A reload of zero leaves the counter at zero, which stops the waveform. The control flag reloads + the counter every frame, holding the note for as long as the registers stay. + + Args: + registers: The chip's registers. + + Returns: + ChannelSound: The channel's sound. + """ + addresses = TONE_ADDRESSES[ChannelName.TRIANGLE] + control = registers.value(addresses.control) + return ChannelSound( + audible=is_enabled(ChannelName.TRIANGLE, registers) and (control & LINEAR_COUNTER_RELOAD) > 0, + period=timer(addresses, registers), + volume=ABSENT_REGISTER, + held=bool(control & TRIANGLE_COUNTER_CONTROL), + timbre=ABSENT_REGISTER, + ) + + +def noise_sound(registers: ChipRegisters) -> ChannelSound: + """The noise channel's sound: the level rides its control byte, the period index and the mode its period byte. + + Args: + registers: The chip's registers. + + Returns: + ChannelSound: The channel's sound. + """ + control = registers.value(NOISE_CONTROL) + period = registers.value(NOISE_PERIOD) + return ChannelSound( + audible=is_enabled(ChannelName.NOISE, registers) and level_sounds(control), + period=period & MAX_PERIOD, + volume=control & MAX_VOLUME, + held=level_held(control), + timbre=period >> NOISE_MODE_SHIFT, + ) + + +def is_enabled( + channel: ChannelName, + registers: ChipRegisters, +) -> bool: + """Whether the status register enables a channel, the first thing a channel needs to sound.""" + return bool(registers.value(APU_STATUS) & CHANNEL_STATUS_BITS[channel]) + + +def timer( + addresses: ToneAddresses, + registers: ChipRegisters, +) -> int: + """The 11-bit timer a pulse or triangle channel's two timer bytes carry together.""" + high = registers.value(addresses.timer_high) & MAX_TIMER_HIGH + return (high << TIMER_HIGH_SHIFT) | registers.value(addresses.timer_low) + + +def level_sounds(control: int) -> bool: + """Whether a pulse or noise control byte sounds: a constant level above zero, or the envelope, opening at full.""" + return not (control & CONSTANT_VOLUME) or (control & MAX_VOLUME) > SILENT_VOLUME + + +def level_held(control: int) -> bool: + """Whether a pulse or noise control byte holds its level: a constant volume under a halted length counter.""" + return (control & SUSTAINED_LEVEL) == SUSTAINED_LEVEL diff --git a/src/sampletones_tools/tracker_playback/trace/registers.py b/src/sampletones_tools/tracker_playback/trace/registers.py new file mode 100644 index 000000000..095dcc3c5 --- /dev/null +++ b/src/sampletones_tools/tracker_playback/trace/registers.py @@ -0,0 +1,53 @@ +from dataclasses import dataclass +from typing import Dict, Final, Iterable, Mapping, Self + +from sampletones_tools.player.trace.write import RegisterWrite + +POWER_UP_VALUE: Final[int] = 0 + + +@dataclass(frozen=True) +class ChipRegisters: + """The value standing in each APU register once a run of writes has landed. + + A register keeps the last value written to it, so the registers on a tick are those the tick + before left with the tick's own writes laid over them. That is what the chip plays from, + whichever registers a player chose to write on the tick. A register never written stands at + zero, the value the APU powers up with, which leaves every channel disabled. + + Attributes: + values: The last value written to each register, by address. + """ + + values: Mapping[int, int] + + @classmethod + def power_up(cls) -> Self: + """The registers before any write lands.""" + return cls(values={}) + + def written(self, writes: Iterable[RegisterWrite]) -> Self: + """The registers once ``writes`` land on them, in order, a later write to a register replacing an earlier one. + + Args: + writes: The writes, in the order the chip takes them. + + Returns: + Self: The registers after the writes. + """ + values: Dict[int, int] = dict(self.values) + for write in writes: + values[write.address] = write.value + + return type(self)(values=values) + + def value(self, address: int) -> int: + """The value standing in one register. + + Args: + address: The register. + + Returns: + int: The last value written to it, or ``POWER_UP_VALUE`` for a register never written. + """ + return self.values.get(address, POWER_UP_VALUE) diff --git a/src/sampletones_tools/tracker_playback/trace/sound.py b/src/sampletones_tools/tracker_playback/trace/sound.py new file mode 100644 index 000000000..5cff2cb91 --- /dev/null +++ b/src/sampletones_tools/tracker_playback/trace/sound.py @@ -0,0 +1,65 @@ +from dataclasses import dataclass +from typing import Dict, Final, Tuple + +from sampletones_core.constants.enums import ChannelName + +ABSENT_REGISTER: Final[int] = 0 + + +@dataclass(frozen=True) +class ChannelSound: + """What one channel sounds on one engine tick, read out of the registers the chip holds. + + Both players are read into this one shape from the registers each writes, so a tick of the + application and a tick of a tracker compare field by field. A register the channel lacks reads + as ``ABSENT_REGISTER`` on both sides. + + Attributes: + audible: Whether the channel sounds at all. + period: The period register: the 11-bit timer on the pulse and triangle channels, and the + 4-bit period index on the noise channel. + volume: The level on the pulse and noise channels, and the envelope's decay period where the + chip runs its envelope. + held: Whether the chip holds the channel where its registers put it. On the pulse and noise + channels the level is constant and the length counter halted, and on the triangle the + control flag halts its linear counter and its length counter together. Where it is + clear, the chip's own envelope and counters move the channel on from there. + timbre: The duty cycle on the pulse channels, and the short mode on the noise channel. + """ + + audible: bool + period: int + volume: int + held: bool + timbre: int + + +@dataclass(frozen=True) +class TickPosition: + """Where in the song one engine tick falls. + + Attributes: + frame: The order frame being played. + row: The row of that frame's patterns. + """ + + frame: int + row: int + + +@dataclass(frozen=True) +class SongTrace: + """What every channel sounds on every engine tick of one pass through a song. + + Attributes: + positions: Where each tick falls, one per tick. + channels: Each channel's sound, one per tick. + """ + + positions: Tuple[TickPosition, ...] + channels: Dict[ChannelName, Tuple[ChannelSound, ...]] + + @property + def ticks(self) -> int: + """The engine ticks the pass lasts.""" + return len(self.positions) diff --git a/tests/conftest.py b/tests/conftest.py index 910b7167d..cc6749031 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,6 +2,7 @@ import pytest +from sampletones_application.utils.gui.modal_queue import ModalQueue from sampletones_application.utils.gui.palette.palette import PaletteBindings from sampletones_core.constants.enums import ChannelName from sampletones_core.reconstructions import Reconstruction @@ -10,6 +11,18 @@ ReconstructionFactory: TypeAlias = Callable[[], Reconstruction] +@pytest.fixture(autouse=True) +def modal_queue() -> Iterator[None]: + """Gives each test an empty modal line. + + The line holds the window standing on the screen and the ones waiting for it, and it outlives + any one context, so each test starts with the screen free and leaves nothing waiting. + """ + ModalQueue.clear() + yield + ModalQueue.clear() + + @pytest.fixture(autouse=True) def palette_bindings() -> Iterator[None]: """Gives each test an empty palette binding registry. diff --git a/tests/integration/bitphase/test_playback_rules.py b/tests/integration/bitphase/test_playback_rules.py index c14a5d062..577582710 100644 --- a/tests/integration/bitphase/test_playback_rules.py +++ b/tests/integration/bitphase/test_playback_rules.py @@ -5,14 +5,15 @@ import pytest from sampletones_core.constants.enums import ChannelName -from sampletones_core.constants.general import HI_PITCH_FACTOR +from sampletones_core.constants.general import HI_PITCH_FACTOR, NUM_PERIODS from sampletones_core.exporters.feature import Features +from sampletones_core.exporters.implementation.noise import NoiseExporter from sampletones_core.exports.request import InstrumentExport, SampleExport from sampletones_core.features.envelope import Envelope from sampletones_core.formats.bitphase.btp import write_btp -from sampletones_core.formats.bitphase.builder import sample_to_bitphase +from sampletones_core.formats.bitphase.builder import instrument_to_bitphase, sample_to_bitphase from sampletones_core.formats.bitphase.notes import pitch_to_note_index -from sampletones_core.formats.bitphase.specification.channels import CHANNEL_LABELS +from sampletones_core.formats.bitphase.specification.channels import CHANNEL_LABELS, ChannelIndex from sampletones_core.formats.bitphase.specification.chip import ( MAX_TUNING_PERIOD, MIN_TUNING_PERIOD, @@ -22,15 +23,26 @@ MIN_VOLUME_OR_RATE, ) from sampletones_core.formats.bitphase.specification.macros import MAX_MACRO_LENGTH +from sampletones_core.formats.bitphase.specification.patterns import ( + FIRST_OCTAVE, + NOTE_RANGE, + TABLE_COLUMN_OFFSET, + NoteName, +) +from sampletones_core.instructions import NoiseInstruction from sampletones_core.timers.arithmetic import bent_timer from sampletones_core.timers.utils import get_timer_table +from sampletones_player.registers.noise import NoiseRegisters from sampletones_shared.constants.music import OCTAVE_SEMITONES from sampletones_shared.music import Tuning from tests.suite.bitphase import ( LoadedInstrument, + LoadedNote, LoadedProject, LoadedTable, + noise_register, parse_btp, + reached_note, sounded_period, ) from tests.suite.case import BaseRegularTestCase @@ -46,6 +58,9 @@ LOWERED_A4_FREQUENCY: Final[float] = 432.0 C5_PITCH: Final[int] = 72 SEMITONES_FROM_A4_TO_C5: Final[int] = 3 +NOISE_WALK: Final[Tuple[int, ...]] = (3, 4, 9, 15, 0, 1, 12, 7, 2) +NOISE_VOLUME: Final[int] = 12 +NOISE_PERIOD_BITS: Final[int] = NUM_PERIODS - 1 @dataclass(frozen=True, kw_only=True) @@ -181,3 +196,49 @@ def test_every_macro_holds_the_values_a_bitphase_instrument_stores(self, documen for instrument in document.instruments for macro in instrument.macros.values() ) + + +def noise_walk() -> List[NoiseInstruction]: + """A noise stream stepping through periods on both sides of where it opens, wrapping past either end.""" + return [NoiseInstruction(on=True, period=period, volume=NOISE_VOLUME, short=False) for period in NOISE_WALK] + + +def cell_note_index(note: LoadedNote) -> int: + """The tuning-table index Bitphase's pattern processor reads back from a note cell.""" + return note.name - int(NoteName.C) + (note.octave - FIRST_OCTAVE) * NOTE_RANGE + + +class TestANoisePeriodTheEngineWrites: + """The project counts noise periods from the slowest and the register counts them from the + fastest, so the NSF player writes a period as its complement. A document sounds the same + register on every tick of a noise slice, the base note and the table step read together. + """ + + @pytest.fixture(name="noise_document") + def noise_document_fixture(self, tmp_path: Path) -> LoadedProject: + instructions = noise_walk() + initial_period = NoiseExporter.derive_initial_pitch(instructions) + request = InstrumentExport( + name="Walk", + channel=ChannelName.NOISE, + features=NoiseExporter.to_features(instructions, initial_period, ()), + nes_frequency=NES_FREQUENCY, + tuning=Tuning(), + ) + destination = tmp_path / "Walk.btp" + write_btp(destination, instrument_to_bitphase(request)) + return parse_btp(destination.read_bytes(), list(CHANNEL_LABELS)) + + def test_every_tick_writes_the_register_the_nsf_player_writes(self, noise_document: LoadedProject) -> None: + song = noise_document.songs[0] + rows = song.patterns[0].channels[int(ChannelIndex.NOISE)].rows + trigger = next(row for row in rows if row.note.name != int(NoteName.NONE)) + table = noise_document.tables[trigger.table - TABLE_COLUMN_OFFSET] + note_index = cell_note_index(trigger.note) + + written = [ + noise_register(reached_note(song.tuning_table, note_index, table, tick)) for tick in range(len(NOISE_WALK)) + ] + played = [registers.period & NOISE_PERIOD_BITS for registers in NoiseRegisters.from_instructions(noise_walk())] + + assert written == played[: len(NOISE_WALK)] diff --git a/tests/integration/sampletones_application/logic/__init__.py b/tests/integration/sampletones_application/logic/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/integration/sampletones_application/logic/reconstruction/__init__.py b/tests/integration/sampletones_application/logic/reconstruction/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/integration/sampletones_application/logic/reconstruction/rewrites/__init__.py b/tests/integration/sampletones_application/logic/reconstruction/rewrites/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/integration/sampletones_application/logic/reconstruction/rewrites/test_queue.py b/tests/integration/sampletones_application/logic/reconstruction/rewrites/test_queue.py new file mode 100644 index 000000000..94bc3516b --- /dev/null +++ b/tests/integration/sampletones_application/logic/reconstruction/rewrites/test_queue.py @@ -0,0 +1,138 @@ +import time +from pathlib import Path +from typing import Final, Iterator, Tuple +from unittest.mock import MagicMock + +import pytest + +from sampletones_application.coordinators.reconstruction import ReconstructionCoordinator +from sampletones_application.coordinators.tabs.reconstruction import ReconstructionTabCoordinator +from sampletones_application.layout.behavior.scheduling.scheduling import SchedulingBehavior +from sampletones_application.logic.reconstruction.manager import ReconstructionManager +from sampletones_application.logic.reconstruction.rewrites.queue import ReconstructionRewrites +from sampletones_application.logic.reconstruction.rewrites.steps import ( + ChannelChange, + RateChange, + StemRemovalRequest, +) +from sampletones_application.services.regeneration.service import RegenerationService +from sampletones_core.constants.enums import FeatureKey +from sampletones_core.features.envelope import Envelope +from sampletones_core.reconstructions import Reconstruction +from tests.suite.application import draw_frame, live_queue, scheduling +from tests.suite.stems import ( + SHARED_CHANNEL, + SHARED_OWNERS, + SOLE_CHANNEL, + STEM_A_ID, + STEM_B_ID, + TAKING_TURNS_PITCH, + taking_turns_file, +) + +__all__ = ["live_queue", "scheduling", "taking_turns_file"] + +DRAG: Final[Tuple[Tuple[int, int], ...]] = ((1, 1), (2, 2), (3, 3), (4, 4), (6, 6)) +ARPEGGIO: Final[Tuple[int, int]] = (4, 3) +RETUNED_FREQUENCY: Final[int] = 50 +SETTLE_SECONDS: Final[float] = 10.0 +POLL_SECONDS: Final[float] = 0.005 + + +@pytest.fixture +def coordinator( + live_queue: None, + scheduling: SchedulingBehavior, + taking_turns_file: Path, +) -> Iterator[ReconstructionCoordinator]: + """The coordinator over the two-recording document opened from its file, rebuilt on the real worker.""" + manager = ReconstructionManager(scheduling=scheduling) + coordinator = ReconstructionCoordinator( + manager, + MagicMock(), + ReconstructionRewrites(manager, RegenerationService()), + MagicMock(), + MagicMock(), + MagicMock(), + dialogs=MagicMock(), + language_manager=MagicMock(), + on_tab_switch=MagicMock(), + on_session_state_changed=MagicMock(), + on_reconstruction_updated=MagicMock(), + ) + coordinator.set_reconstructions_tab(MagicMock(spec=ReconstructionTabCoordinator)) + manager.load_reconstruction(taking_turns_file) + yield coordinator + + +def _settle(coordinator: ReconstructionCoordinator) -> None: + """Draws frames until every step has landed, the way the render loop drains the results.""" + deadline = time.monotonic() + SETTLE_SECONDS + while coordinator._rewrites.is_busy: + assert time.monotonic() < deadline, "the steps never settled" + draw_frame() + time.sleep(POLL_SECONDS) + + +def _open_document(coordinator: ReconstructionCoordinator) -> Reconstruction: + reconstruction = coordinator._reconstruction_manager.reconstruction + assert reconstruction is not None + return reconstruction + + +class TestEditingAFileBackedDocumentAtSpeed: + """A drag, an arpeggio, a removal and a rate change made faster than the worker rebuilds all land, in order.""" + + @pytest.fixture + def settled(self, coordinator: ReconstructionCoordinator) -> ReconstructionCoordinator: + for volume in DRAG: + coordinator.request_rewrite( + ChannelChange( + channel_name=SHARED_CHANNEL, + feature_key=FeatureKey.VOLUME, + envelopes={FeatureKey.VOLUME: Envelope[int](items=volume)}, + initial_pitch=None, + ) + ) + coordinator.request_rewrite( + ChannelChange( + channel_name=SHARED_CHANNEL, + feature_key=FeatureKey.ARPEGGIO, + envelopes={FeatureKey.ARPEGGIO: Envelope[int](items=ARPEGGIO)}, + initial_pitch=None, + ) + ) + coordinator.request_rewrite(StemRemovalRequest(stem_id=STEM_B_ID, stem_name="b")) + coordinator.request_rewrite(RateChange(nes_frequency=RETUNED_FREQUENCY)) + _settle(coordinator) + return coordinator + + def test_the_drag_lands_where_it_ended(self, settled: ReconstructionCoordinator) -> None: + kept = _open_document(settled).instructions[SHARED_CHANNEL][SHARED_OWNERS.index(STEM_A_ID)] + + assert kept.volume == DRAG[-1][0] + + def test_the_arpeggio_lands_beside_it(self, settled: ReconstructionCoordinator) -> None: + kept = _open_document(settled).instructions[SHARED_CHANNEL][SHARED_OWNERS.index(STEM_A_ID)] + + assert kept.pitch == TAKING_TURNS_PITCH + ARPEGGIO[0] + + def test_the_recording_taken_out_stays_out(self, settled: ReconstructionCoordinator) -> None: + reconstruction = _open_document(settled) + + assert list(reconstruction.stems_data.config.entries_by_id) == [STEM_A_ID] + assert SOLE_CHANNEL not in reconstruction.playing_channels + + def test_the_recording_that_stays_keeps_its_audio(self, settled: ReconstructionCoordinator) -> None: + data = settled._reconstruction_manager.current_reconstruction + + assert data is not None + assert len(data.stem_audios) == 1 + + def test_the_document_runs_at_the_new_rate(self, settled: ReconstructionCoordinator) -> None: + assert _open_document(settled).config.nes_frequency == RETUNED_FREQUENCY + + def test_the_waveform_fades_for_the_whole_span(self, settled: ReconstructionCoordinator) -> None: + dims = [entry.args[0] for entry in settled._tab.set_reconstruction_dimmed.call_args_list] + + assert dims == [True, False] diff --git a/tests/integration/sampletones_application/services/test_regeneration.py b/tests/integration/sampletones_application/services/test_regeneration.py index 3d702dff0..8f90b6562 100644 --- a/tests/integration/sampletones_application/services/test_regeneration.py +++ b/tests/integration/sampletones_application/services/test_regeneration.py @@ -54,7 +54,6 @@ def test_run_emits_service_success(self, reconstruction_data, pulse_features) -> service._run( reconstruction_data.reconstruction, ChannelName.PULSE1, - FeatureKey.VOLUME, pulse_features, reconstruction_data.reconstruction.recorded_stem_ids, ) @@ -70,7 +69,6 @@ def test_run_emits_new_reconstruction_carrying_the_edit(self, reconstruction_dat service._run( reconstruction_data.reconstruction, ChannelName.PULSE1, - FeatureKey.VOLUME, pulse_features, reconstruction_data.reconstruction.recorded_stem_ids, ) @@ -78,8 +76,6 @@ def test_run_emits_new_reconstruction_carrying_the_edit(self, reconstruction_dat emitted = results[0].value assert emitted.reconstruction is not reconstruction_data.reconstruction assert len(emitted.reconstruction.approximations.get(ChannelName.PULSE1, np.array([], dtype=np.float32))) > 0 - assert emitted.channel_name is ChannelName.PULSE1 - assert emitted.feature_key is FeatureKey.VOLUME def test_run_updates_reconstruction_approximation(self, reconstruction_data, pulse_features) -> None: service = RegenerationService() @@ -87,7 +83,6 @@ def test_run_updates_reconstruction_approximation(self, reconstruction_data, pul service._run( reconstruction_data.reconstruction, ChannelName.PULSE1, - FeatureKey.VOLUME, pulse_features, reconstruction_data.reconstruction.recorded_stem_ids, ) @@ -103,7 +98,6 @@ def test_run_updates_reconstruction_instructions(self, reconstruction_data, puls service._run( reconstruction_data.reconstruction, ChannelName.PULSE1, - FeatureKey.VOLUME, pulse_features, reconstruction_data.reconstruction.recorded_stem_ids, ) @@ -124,7 +118,6 @@ def test_run_regenerates_from_the_envelopes_it_is_handed(self, reconstruction_da service._run( reconstruction_data.reconstruction, ChannelName.PULSE1, - FeatureKey.VOLUME, silenced, reconstruction_data.reconstruction.recorded_stem_ids, ) @@ -142,7 +135,6 @@ def test_run_emits_service_error_for_wrong_features_type(self, reconstruction_da service._run( reconstruction_data.reconstruction, ChannelName.PULSE1, - FeatureKey.VOLUME, {}, reconstruction_data.reconstruction.recorded_stem_ids, ) @@ -158,7 +150,6 @@ def test_start_completes_through_full_pipeline(self, reconstruction_data, pulse_ service.start( reconstruction_data.reconstruction, ChannelName.PULSE1, - FeatureKey.VOLUME, pulse_features, reconstruction_data.reconstruction.recorded_stem_ids, ) @@ -191,7 +182,6 @@ def _edit_arpeggio(context: ArpeggioEditContext, arpeggio: np.ndarray) -> None: service._run( context.reconstruction, ChannelName.PULSE1, - FeatureKey.ARPEGGIO, edited, context.reconstruction.recorded_stem_ids, ) @@ -312,7 +302,6 @@ def test_result_delivered_despite_future_lower_priority_task(self, reconstructio service._run( reconstruction_data.reconstruction, ChannelName.PULSE1, - FeatureKey.VOLUME, pulse_features, reconstruction_data.reconstruction.recorded_stem_ids, ) @@ -343,7 +332,6 @@ def test_edit_reaches_subscriber_within_frame_budget(self, reconstruction_data, service._run( reconstruction_data.reconstruction, ChannelName.PULSE1, - FeatureKey.VOLUME, pulse_features, reconstruction_data.reconstruction.recorded_stem_ids, ) @@ -357,5 +345,3 @@ def test_edit_reaches_subscriber_within_frame_budget(self, reconstruction_data, assert len(delivered) == 1 regenerated = delivered[0].value assert regenerated.reconstruction is not reconstruction_data.reconstruction - assert regenerated.channel_name is ChannelName.PULSE1 - assert regenerated.feature_key is FeatureKey.VOLUME diff --git a/tests/suite/application.py b/tests/suite/application.py index 100432b75..cd4979664 100644 --- a/tests/suite/application.py +++ b/tests/suite/application.py @@ -59,7 +59,6 @@ def scheduling() -> SchedulingBehavior: return SchedulingBehavior( delays=SchedulingDelays( schedule=0, - reconstruction_update=0, cancel=0, ), priorities=SchedulingPriorities( diff --git a/tests/suite/bitphase.py b/tests/suite/bitphase.py index 729fc7af6..940bbc2a2 100644 --- a/tests/suite/bitphase.py +++ b/tests/suite/bitphase.py @@ -32,6 +32,9 @@ BITPHASE_MAX_MACRO_LENGTH: Final[int] = 512 BITPHASE_SILENT_PERIOD: Final[int] = 0 BITPHASE_MAX_PERIOD: Final[int] = 2047 +BITPHASE_OPENING_PATTERN_VOLUME: Final[int] = 15 +BITPHASE_STORED_VOLUME_OFF: Final[int] = -1 +BITPHASE_SILENCED_PATTERN_VOLUME: Final[int] = 0 BITPHASE_MACRO_DEFAULTS: Final[Dict[str, Any]] = { "pulseWidth": 2, "volumeOrRate": 15, @@ -45,6 +48,37 @@ "sweepShift": 0, } +BITPHASE_NOISE_PERIOD_COUNT: Final[int] = 16 +BITPHASE_NOTE_OFF: Final[int] = 1 +BITPHASE_NO_NOTE: Final[int] = 0 +BITPHASE_FIRST_NOTE_NAME: Final[int] = 2 +BITPHASE_NOTE_RANGE: Final[int] = 12 +BITPHASE_FIRST_OCTAVE: Final[int] = 1 +BITPHASE_TABLE_OFF: Final[int] = -1 +BITPHASE_TABLE_COLUMN_OFFSET: Final[int] = 1 +BITPHASE_ORNAMENT_POSITION: Final[int] = 5 +BITPHASE_ORNAMENT_POSITION_MASK: Final[int] = 0xFF +BITPHASE_SPEED_EFFECT: Final[int] = ord("S") +BITPHASE_FIRST_STEP: Final[int] = 0 +BITPHASE_NOISE_TIMERS: Final[Tuple[int, ...]] = ( + 4, + 8, + 16, + 32, + 64, + 96, + 128, + 160, + 202, + 254, + 380, + 508, + 762, + 1016, + 2034, + 4068, +) + MIN_INITIAL_SPEED: Final[int] = 1 MAX_INITIAL_SPEED: Final[int] = 255 MIN_PATTERN_LENGTH: Final[int] = 1 @@ -218,11 +252,76 @@ def sounded_period( Returns: int: The period the channel holds, within the timer's range. """ - moved = min(max(note_index + table.step(tick), 0), len(tuning_table) - 1) + moved = reached_note(tuning_table, note_index, table, tick) period = tuning_table[moved] + instrument.value("toneAdd", tick) return min(max(period, BITPHASE_SILENT_PERIOD), BITPHASE_MAX_PERIOD) +def noise_register(note_index: int) -> int: + """The period register value the noise channel writes for the note it reaches, as the engine resolves it. + + The driver counts the note index down from the top of each cycle of sixteen, and the register + selects the timer from ``BITPHASE_NOISE_TIMERS``, the NTSC table fastest first. Read from + ``resolveNesNoisePeriodFromSemitoneOffset`` of ``nes-audio-driver.js``, the ``$400E`` write of + ``nes-apu-engine.js`` and ``wavlen_table`` of ``nsfplug/nes_dmc.c`` at commit ``265ff70``. + + Args: + note_index: The note the channel reaches, its table step added. + + Returns: + int: The value the period register holds. + """ + return BITPHASE_NOISE_PERIOD_COUNT - 1 - note_index % BITPHASE_NOISE_PERIOD_COUNT + + +def reached_note( + tuning_table: List[int], + note_index: int, + table: LoadedTable, + tick: int, +) -> int: + """The note a channel reaches on a tick of a sounding note, held within the tuning table. + + Read from ``processTables`` of ``tracker-pattern-processor.js`` at commit ``265ff70``. + + Args: + tuning_table: The song's period per note index. + note_index: The note the pattern cell names. + table: The table the cell attaches. + tick: Ticks since the table started. + + Returns: + int: The note index the channel sounds. + """ + return min(max(note_index + table.step(tick), 0), len(tuning_table) - 1) + + +def pattern_volume(carried: int, stored: int) -> int: + """The level a channel plays at once a row's stored volume cell is read, as the engine reads it. + + A stored ``-1`` silences the channel, a level above zero replaces the one it carries, and any + other value leaves the carried level alone. A channel opens at the full level. Read from + ``_processVolume`` of ``tracker-pattern-processor.js`` and ``nes-state.js`` of the tracker at + commit ``265ff70``. The triangle sounds a full-level instrument while this level is above zero, + since the driver enables it on the PT3 product of the two, which for a full instrument is the + pattern level itself. + + Args: + carried: The level the channel carries into the row. + stored: The row's stored volume cell. + + Returns: + int: The level the channel plays the row at. + """ + if stored == BITPHASE_STORED_VOLUME_OFF: + return BITPHASE_SILENCED_PATTERN_VOLUME + + if stored > BITPHASE_SILENCED_PATTERN_VOLUME: + return stored + + return carried + + def _note(data: Optional[Dict[str, Any]]) -> LoadedNote: source = data or {} return LoadedNote( @@ -405,3 +504,104 @@ def parse_btp(data: bytes, channel_labels: List[str]) -> LoadedProject: tables=[_table(table) for table in document.get("tables") or []], instruments=[_instrument(instrument) for instrument in document.get("instruments") or []], ) + + +def note_value(note: LoadedNote) -> int: + """The note index a pattern cell names, as ``_processNote`` of the tracker at commit ``265ff70`` reads it.""" + return note.name - BITPHASE_FIRST_NOTE_NAME + (note.octave - BITPHASE_FIRST_OCTAVE) * BITPHASE_NOTE_RANGE + + +def _next_step(position: int, table: LoadedTable) -> int: + """The step a table moves to after a tick, circling from its loop where it stands among the steps.""" + following = position + 1 + if following < len(table.rows): + return following + + 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. + + 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. + """ + 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 + + 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)] + + +@dataclass +class _ChannelReplay: + """What one channel carries from row to row while the document plays.""" + + note: Optional[int] = None + table: Optional[LoadedTable] = None + position: int = BITPHASE_FIRST_STEP + + def read(self, row: LoadedRow, tables: Dict[int, LoadedTable]) -> None: + """Moves the channel onto one row: its note, then its table, then its effects. + + Read from ``parsePatternRow``, ``_processNote``, ``_processTable`` and + ``_initChannelOrnamentPosition`` of ``tracker-pattern-processor.js`` at commit ``265ff70``. + """ + if row.note.name == BITPHASE_NOTE_OFF: + self.note = None + elif row.note.name != BITPHASE_NO_NOTE: + self.note = note_value(row.note) + self.position = BITPHASE_FIRST_STEP + + if row.table == BITPHASE_TABLE_OFF: + self.table = None + self.position = BITPHASE_FIRST_STEP + elif row.table > 0: + self.table = tables[row.table - BITPHASE_TABLE_COLUMN_OFFSET] + self.position = BITPHASE_FIRST_STEP + + for effect in row.effects: + if effect is not None and effect.effect == BITPHASE_ORNAMENT_POSITION: + self.position = effect.parameter & BITPHASE_ORNAMENT_POSITION_MASK + + def tick(self, tuning_table: List[int]) -> Optional[int]: + """The note the channel sounds on one tick, its table stepping on after it. + + Read from ``processTables`` of ``tracker-pattern-processor.js`` at commit ``265ff70``. + """ + if self.note is None or self.table is None: + return self.note + + step = self.table.rows[self.position] if self.position < len(self.table.rows) else BITPHASE_FIRST_STEP + self.position = _next_step(self.position, self.table) + return min(max(self.note + step, 0), len(tuning_table) - 1) + + +def played_notes(document: LoadedProject, channel_index: int) -> List[Optional[int]]: + """The note index one channel sounds on each tick, the order played once through as the engine reads it. + + Args: + document: The document as Bitphase loads it. + channel_index: The channel whose notes are read. + + Returns: + List[Optional[int]]: One note per tick, and ``None`` where the channel holds no note. + """ + song = document.songs[0] + patterns = {pattern.id: pattern for pattern in song.patterns} + tables = {table.id: table for table in document.tables} + replay = _ChannelReplay() + notes: List[Optional[int]] = [] + for pattern_id in document.pattern_order: + pattern = patterns[pattern_id] + for row, speed in zip(pattern.channels[channel_index].rows, _row_speeds(document, pattern)): + replay.read(row, tables) + notes.extend(replay.tick(song.tuning_table) for _ in range(speed)) + + return notes diff --git a/tests/suite/famitracker.py b/tests/suite/famitracker.py index d1245c514..1f3f21394 100644 --- a/tests/suite/famitracker.py +++ b/tests/suite/famitracker.py @@ -1,6 +1,6 @@ import struct -from dataclasses import dataclass -from typing import Dict, List, Tuple +from dataclasses import dataclass, field +from typing import Dict, Final, List, Optional, Sequence, Tuple from sampletones_core.formats.famitracker.specification.blocks import BLOCK_NAME_LENGTH from sampletones_core.formats.famitracker.specification.file import ( @@ -15,6 +15,172 @@ SEQUENCE_COUNT_2A03, ) +FAMITRACKER_OPENING_VOLUME: Final[int] = 15 +FAMITRACKER_EMPTY_VOLUME: Final[int] = 0x10 +FAMITRACKER_EMPTY_INSTRUMENT: Final[int] = 0x40 +FAMITRACKER_FIRST_NOTE: Final[int] = 1 +FAMITRACKER_LAST_NOTE: Final[int] = 12 +FAMITRACKER_HALT: Final[int] = 14 +FAMITRACKER_NOTE_RANGE: Final[int] = 12 +FAMITRACKER_NOTE_COUNT: Final[int] = 96 +FAMITRACKER_NOISE_CHANNEL: Final[int] = 3 +FAMITRACKER_NOISE_MASK: Final[int] = 0x0F +FAMITRACKER_SLIDE_UP: Final[int] = 20 +FAMITRACKER_SLIDE_DOWN: Final[int] = 21 +FAMITRACKER_SLIDE_SEMITONES_MASK: Final[int] = 0x0F +FAMITRACKER_ARPEGGIO: Final[int] = 1 +FAMITRACKER_NO_LOOP: Final[int] = -1 + + +def column_volume(carried: int, stored: int) -> int: + """The level a channel plays at once a row's volume cell is read, as FamiTracker reads it. + + A stored level below the empty value replaces the level the channel carries, and the empty value + leaves it alone. A channel opens at the full level. The triangle sounds while this level is above + zero and its instrument's volume is too. Read from ``CChannelHandler::ResetChannel`` and + ``CChannelHandler::HandleNoteData`` in ``ChannelHandler.cpp`` and ``CTriangleChan::RefreshChannel`` in + ``Channels2A03.cpp`` of 0CC-FamiTracker 0.3.15.3. + + Args: + carried: The level the channel carries into the row. + stored: The row's stored volume cell. + + Returns: + int: The level the channel plays the row at. + """ + if stored < FAMITRACKER_EMPTY_VOLUME: + return stored + + return carried + + +@dataclass +class _ArpeggioRun: + """An instrument's arpeggio sequence as the channel steps through it, one item per tick.""" + + items: List[int] + loop_point: int + pointer: int = field(default=0) + running: bool = field(default=True) + + def step(self) -> int: + """The item this tick reads, the sequence moving on after it and halting past its end without a loop.""" + item = self.items[self.pointer] + self.pointer += 1 + if self.pointer >= len(self.items): + if self.loop_point == FAMITRACKER_NO_LOOP: + self.running = False + else: + self.pointer = self.loop_point + + return item + + +@dataclass +class _ChannelReplay: + """What one channel carries from row to row while the module plays.""" + + noise: bool + note: Optional[int] = None + sounded: Optional[int] = None + instrument: int = field(default=FAMITRACKER_EMPTY_INSTRUMENT) + arpeggio: Optional[_ArpeggioRun] = None + + def read(self, row: "ParsedRow", arpeggios: Dict[int, Optional[_ArpeggioRun]]) -> None: + """Moves the channel onto one row: its slides, then its note, which triggers the instrument. + + Read from ``CChannelHandler::HandleNoteData`` and ``CChannelHandler::SetupSlide`` in + ``ChannelHandler.cpp`` and ``CNoiseChan::HandleNote`` in ``Channels2A03.cpp`` of 0CC-FamiTracker + 0.3.15.3. A slide moves the note by its semitones at once, and a halt silences the channel. + """ + for effect, parameter in row.effects: + if self.note is None: + continue + if effect == FAMITRACKER_SLIDE_UP: + self.note += parameter & FAMITRACKER_SLIDE_SEMITONES_MASK + elif effect == FAMITRACKER_SLIDE_DOWN: + self.note -= parameter & FAMITRACKER_SLIDE_SEMITONES_MASK + + if row.note == FAMITRACKER_HALT: + self.note = None + elif FAMITRACKER_FIRST_NOTE <= row.note <= FAMITRACKER_LAST_NOTE: + value = row.octave * FAMITRACKER_NOTE_RANGE + row.note - FAMITRACKER_FIRST_NOTE + self.note = value & FAMITRACKER_NOISE_MASK if self.noise else value + self.sounded = self._resolved(self.note) + if row.instrument != FAMITRACKER_EMPTY_INSTRUMENT: + self.instrument = row.instrument + + template = arpeggios.get(self.instrument) + self.arpeggio = None if template is None else _ArpeggioRun(template.items, template.loop_point) + + def tick(self) -> Optional[int]: + """The note the channel sounds on one tick. + + While the arpeggio runs it reloads the period from the note moved by its item every tick; + once it halts, or where the instrument has none, the period stays where it was. Read from + ``CSeqInstHandler::ProcessSequence`` and ``CSeqInstHandler::UpdateInstrument`` in + ``SeqInstHandler.cpp`` of 0CC-FamiTracker 0.3.15.3. + """ + if self.note is None: + return None + + if self.arpeggio is not None and self.arpeggio.running: + self.sounded = self._resolved(self.note + self.arpeggio.step()) + + return self.sounded + + def _resolved(self, note: int) -> int: + """The note a period is read for: the low four bits on noise, the note table's range elsewhere.""" + if self.noise: + return note & FAMITRACKER_NOISE_MASK + + return min(max(note, 0), FAMITRACKER_NOTE_COUNT - 1) + + +def played_notes( + module: "ParsedModule", + channel_id: int, + row_ticks: Sequence[int], +) -> 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. + + 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. + + Returns: + List[Optional[int]]: One note per tick, and ``None`` where the channel holds no note. + """ + sequences = {(sequence.sequence_type, sequence.index): sequence for sequence in module.sequences} + arpeggios: Dict[int, Optional[_ArpeggioRun]] = {} + for instrument in module.instruments: + enabled, index = instrument.sequence_refs[FAMITRACKER_ARPEGGIO] + arpeggio = sequences[(FAMITRACKER_ARPEGGIO, index)] if enabled else None + arpeggios[instrument.index] = None if arpeggio is None else _ArpeggioRun(arpeggio.items, arpeggio.loop_point) + + patterns = { + pattern.index: {row.row_number: row for row in pattern.rows} + for pattern in module.patterns + if pattern.channel == channel_id + } + replay = _ChannelReplay(noise=channel_id == FAMITRACKER_NOISE_CHANNEL) + notes: List[Optional[int]] = [] + for frame in 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])) + + return notes + class _Cursor: def __init__(self, data: bytes) -> None: @@ -294,7 +460,7 @@ def _parse_patterns( octave = cursor.read_int8() instrument = cursor.read_int8() volume = cursor.read_int8() - effects = [(cursor.read_int8(), cursor.read_int8()) for _ in range(effect_columns_by_channel[channel])] + effects = [(cursor.read_int8(), cursor.read_uint8()) for _ in range(effect_columns_by_channel[channel])] rows.append( ParsedRow( row_number=row_number, diff --git a/tests/suite/frames.py b/tests/suite/frames.py index a2653aed2..7236013e4 100644 --- a/tests/suite/frames.py +++ b/tests/suite/frames.py @@ -1,7 +1,9 @@ from typing import Final, List, Tuple import dearpygui.dearpygui as dpg +import pytest +from sampletones_application.utils.gui.frame import FrameCallbackManager from sampletones_shared.types.callback import VoidCallback ONE_FRAME: Final[int] = 1 @@ -43,6 +45,14 @@ def render(self, frames: int = ONE_FRAME) -> None: callback() +@pytest.fixture +def held_frames(monkeypatch: pytest.MonkeyPatch) -> Frames: + """``FrameCallbackManager`` holding the work handed to it until the case renders a frame.""" + frames = Frames() + monkeypatch.setattr(FrameCallbackManager, "set_frame_callback", frames.hold) + return frames + + VISIBLE_HANDLER: Final[str] = "mvAppItemType::mvVisibleHandler" diff --git a/tests/suite/gates.py b/tests/suite/gates.py new file mode 100644 index 000000000..5c54f9f9c --- /dev/null +++ b/tests/suite/gates.py @@ -0,0 +1,40 @@ +from typing import List + +import pytest + +from sampletones_shared.types.callback import VoidCallback + + +class HeldGate: + """A gate a case opens by hand, the way the open reconstruction's edits hold a gesture until they land.""" + + def __init__(self) -> None: + self.holding = False + self._held: List[VoidCallback] = [] + + def __call__(self, proceed: VoidCallback) -> None: + if self.holding: + self._held.append(proceed) + return + + proceed() + + @property + def held(self) -> int: + """How many gestures wait at the gate.""" + return len(self._held) + + def release(self) -> None: + """Opens the gate, which runs every gesture waiting at it in the order it arrived.""" + self.holding = False + held, self._held = self._held, [] + for proceed in held: + proceed() + + +@pytest.fixture +def held_gate() -> HeldGate: + """A gate holding every gesture that reaches it until the case releases it.""" + gate = HeldGate() + gate.holding = True + return gate diff --git a/tests/suite/performance.py b/tests/suite/performance.py index efef5b1c5..7d6c93646 100644 --- a/tests/suite/performance.py +++ b/tests/suite/performance.py @@ -15,9 +15,13 @@ PulseInstruction, TriangleInstruction, ) +from sampletones_core.performance.rows import apply_row, resolve_row +from sampletones_core.performance.state import ChannelPerformance from sampletones_core.project.patterns.row import Row from sampletones_core.project.project import Project from sampletones_core.project.settings import ProjectSettings +from sampletones_core.project.song import Song +from sampletones_core.project.song_position import SongPosition from sampletones_core.project.voices.instrument import Instrument from sampletones_core.project.voices.note_on import NoteOn from sampletones_core.project.voices.sample import Sample @@ -46,6 +50,14 @@ def _reconstruction( ) +def reconstruction_of( + channel_name: ChannelName, + instructions: List[InstructionUnion], +) -> Reconstruction: + """A reconstruction sounding ``instructions`` on one channel, every other channel standing by.""" + return _reconstruction(channel_name, instructions) + + def make_pulse_reconstruction( *, pitch: int = 60, @@ -159,3 +171,23 @@ def place_instrument( transpose=transpose, volume=volume, ) + + +def song_row_volumes(song: Song, channel_name: ChannelName) -> List[Optional[int]]: + """The level the song walk plays one channel at on each row of the order, ``None`` where it rests. + + Rows come frame by frame, each frame lasting the song's rows per pattern, so an index into the + list names the same row a tracker document laying the order out in turn plays there. + """ + performance = ChannelPerformance() + volumes: List[Optional[int]] = [] + position = SongPosition() + while position.order_position < song.order_length(): + row = resolve_row(song, position, channel_name) + if row is not None: + apply_row(performance, row) + + volumes.append(performance.volume if performance.voice_id is not None else None) + position.advance(song.rows_per_pattern, song.order_length()) + + return volumes diff --git a/tests/suite/playback.py b/tests/suite/playback.py new file mode 100644 index 000000000..36baa5382 --- /dev/null +++ b/tests/suite/playback.py @@ -0,0 +1,40 @@ +from pathlib import Path +from typing import Final, List, Tuple + +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 + +REPLAYING_TITLE: Final[str] = "Replay" +REPLAYING_PLAYER: Final[str] = "the application itself" +REPLAYED_EXTENSION: Final[str] = ".txt" + + +class ReplayingTarget: + """A playback target that plays every project back exactly as the application plays it. + + It writes a stand-in file per project and records what it was asked to play, so a case checks the + run around a target without any tracker installed. + """ + + title: str = REPLAYING_TITLE + player: str = REPLAYING_PLAYER + + def __init__(self) -> None: + self.played: List[Tuple[Path, str]] = [] + + def play( + self, + project: Project, + directory: Path, + name: str, + ) -> TargetPlayback: + self.played.append((directory, name)) + document = directory / f"{name}{REPLAYED_EXTENSION}" + document.write_text(name, encoding="utf-8") + return TargetPlayback( + document=document, + trace=application_trace(project), + skipped_rows=0, + truncation=None, + ) diff --git a/tests/suite/regeneration.py b/tests/suite/regeneration.py new file mode 100644 index 000000000..5832f2862 --- /dev/null +++ b/tests/suite/regeneration.py @@ -0,0 +1,67 @@ +from dataclasses import dataclass +from typing import AbstractSet, Callable, List, Tuple + +from sampletones_application.services.regeneration.result import ( + RegeneratedInstrument, + RegenerationResult, +) +from sampletones_application.services.regeneration.service import RegenerationService +from sampletones_application.services.result import ServiceError, ServiceSuccess +from sampletones_core.constants.enums import ChannelName +from sampletones_core.exporters import Features +from sampletones_core.reconstructions import Reconstruction + + +@dataclass(frozen=True) +class HeldRebuild: + """One rebuild the regeneration was asked for, as it was asked.""" + + reconstruction: Reconstruction + channel_name: ChannelName + features: Features + heard: AbstractSet[int] + + +class HeldRegeneration: + """The regeneration service holding each rebuild it is asked for until a case lets it finish. + + A rebuild runs on a worker thread in the application, so its result arrives some time after it + was asked for. Holding it lets a case put gestures in between, and finishing it runs the real + rebuild. + """ + + def __init__(self) -> None: + self._service = RegenerationService() + self._held: List[HeldRebuild] = [] + + def subscribe(self, handler: Callable[[RegenerationResult], None]) -> None: + self._service.subscribe(handler) + + def start( + self, + reconstruction: Reconstruction, + channel_name: ChannelName, + features: Features, + heard: AbstractSet[int], + ) -> None: + self._held.append(HeldRebuild(reconstruction, channel_name, features, heard)) + + @property + def held(self) -> Tuple[HeldRebuild, ...]: + """The rebuilds asked for and not yet finished, the earliest first.""" + return tuple(self._held) + + def finish(self) -> None: + """Runs the earliest held rebuild and delivers what it produced.""" + rebuild = self._held.pop(0) + self._service._run(rebuild.reconstruction, rebuild.channel_name, rebuild.features, rebuild.heard) + + def finish_with(self, reconstruction: Reconstruction) -> None: + """Delivers ``reconstruction`` as what the earliest held rebuild produced.""" + self._held.pop(0) + self._service._emit(ServiceSuccess(value=RegeneratedInstrument(reconstruction=reconstruction))) + + def fail(self, exception: Exception) -> None: + """Delivers ``exception`` as the earliest held rebuild's failure.""" + self._held.pop(0) + self._service._emit(ServiceError(exception=exception)) diff --git a/tests/suite/silent_rows.py b/tests/suite/silent_rows.py index a6478c087..886dd48e8 100644 --- a/tests/suite/silent_rows.py +++ b/tests/suite/silent_rows.py @@ -1,7 +1,7 @@ from typing import Final, List from sampletones_core.constants.enums import ChannelName -from sampletones_core.exporters.skipped import SkippedRow +from sampletones_core.exporters.skipped import SkippedRow, SkipReason from sampletones_core.project.patterns.channel import Channel from sampletones_core.project.patterns.pattern import Pattern from sampletones_core.project.patterns.row import Row @@ -28,4 +28,5 @@ def name_a_missing_voice(project: Project) -> SkippedRow: channel=SILENT_CHANNEL, order_position=0, row_index=SILENT_ROW, + reason=SkipReason.NO_INSTRUMENT, ) diff --git a/tests/suite/stems.py b/tests/suite/stems.py index be875a4d2..c6278ce11 100644 --- a/tests/suite/stems.py +++ b/tests/suite/stems.py @@ -2,12 +2,14 @@ from typing import Dict, Final, List, Mapping, Sequence, Tuple import numpy as np +import pytest from sampletones_core.audio import write_wave from sampletones_core.configs import Config from sampletones_core.constants.algorithm import RESTING_STEM_ID from sampletones_core.constants.enums import ChannelName, HierarchyMode, bending_channels -from sampletones_core.instructions import InstructionUnion +from sampletones_core.exporters import CHANNEL_TO_EXPORTER_MAP, Features +from sampletones_core.instructions import InstructionUnion, PulseInstruction from sampletones_core.reconstructions import Reconstruction from sampletones_core.reconstructions.reconstruction.instructions import InstructionsItem from sampletones_core.reconstructions.reconstruction.stems.channel_assignment import ChannelAssignment @@ -37,6 +39,12 @@ STEM_RECORDING_DURATION_SECONDS: Final[float] = 0.5 RECORDED_SCALE: Final[float] = 1.0 RECORDING_SEED: Final[int] = 93 +SHARED_CHANNEL: Final[ChannelName] = ChannelName.PULSE1 +SOLE_CHANNEL: Final[ChannelName] = ChannelName.PULSE2 +SHARED_OWNERS: Final[Tuple[int, ...]] = (STEM_A_ID, STEM_B_ID) +TAKING_TURNS_PITCH: Final[int] = 60 +TAKING_TURNS_VOLUME: Final[int] = 8 +TAKING_TURNS_DUTY_CYCLE: Final[int] = 0 def single_entry_stems_data( @@ -116,6 +124,23 @@ def everything_heard(reconstruction: Reconstruction) -> StemSelection: ) +def regenerated( + reconstruction: Reconstruction, + channel_name: ChannelName, + features: Features, +) -> Reconstruction: + """The document a regeneration leaves once it rebuilds one channel from ``features``, every recording heard.""" + rebuilt = reconstruction.model_copy(deep=True) + rebuilt.update_channel_data( + channel_name, + list(CHANNEL_TO_EXPORTER_MAP[channel_name].from_features(features)), + features.initial_pitch, + features.held_features, + heard=rebuilt.recorded_stem_ids, + ) + return rebuilt + + def recorded_from( reconstruction: Reconstruction, paths: Sequence[Path], @@ -157,3 +182,63 @@ def recorded_from( scale=RECORDED_SCALE, ).with_sources(paths) return reconstruction.rewritten(streams, stems_data) + + +def taking_turns_reconstruction(sources: Sequence[Path]) -> Reconstruction: + """Two recordings taking turns on the shared channel, the second holding the sole channel alone. + + The shared channel plays one frame per recording, the first recording's and then the second's, + and the sole channel plays one frame of the second recording. Taking the second recording out + therefore releases a frame on each channel, and leaves the sole channel standing by. + + Args: + sources: The files the two recordings were read from, the first recording's first. + """ + instruction = PulseInstruction( + on=True, + pitch=TAKING_TURNS_PITCH, + volume=TAKING_TURNS_VOLUME, + duty_cycle=TAKING_TURNS_DUTY_CYCLE, + ) + instructions: Dict[ChannelName, List[InstructionUnion]] = { + SHARED_CHANNEL: [instruction] * len(SHARED_OWNERS), + SOLE_CHANNEL: [instruction], + } + stems_data = StemsData( + config=StemsConfig( + entries=[ + StemEntry(id=STEM_A_ID, settings=StemSettings.covering([SHARED_CHANNEL])), + StemEntry(id=STEM_B_ID, settings=StemSettings.covering([SHARED_CHANNEL, SOLE_CHANNEL])), + ], + hierarchy=StemsHierarchy(levels=[[STEM_A_ID, STEM_B_ID]]), + ), + assignments=[ + ChannelAssignment(channel_name=SHARED_CHANNEL, stem_ids=list(SHARED_OWNERS)), + ChannelAssignment(channel_name=SOLE_CHANNEL, stem_ids=[STEM_B_ID]), + ], + scale=RECORDED_SCALE, + ).with_sources(sources) + return Reconstruction.create( + instructions=instructions, + config=Config(), + coefficient=1.0, + audio_filepath=tuple(sources), + stems_data=stems_data, + ) + + +@pytest.fixture +def taking_turns(tmp_path: Path) -> Reconstruction: + """The two-recording document, naming recordings this machine holds nowhere.""" + return taking_turns_reconstruction((tmp_path / "a.wav", tmp_path / "b.wav")) + + +@pytest.fixture +def taking_turns_file(tmp_path: Path) -> Path: + """The two-recording document saved to a file beside the recordings it was read from.""" + recordings = tmp_path / "recordings" + recordings.mkdir() + first, second, _ = write_three_stem_recordings(three_stem_reconstruction_config(), recordings) + path = tmp_path / "turns.stn" + taking_turns_reconstruction((first, second)).save(path) + return path diff --git a/tests/suite/transposes.py b/tests/suite/transposes.py new file mode 100644 index 000000000..162f50092 --- /dev/null +++ b/tests/suite/transposes.py @@ -0,0 +1,140 @@ +from typing import Dict, Final, List, Mapping, Optional, Sequence, Tuple + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.general import MAX_PERIOD +from sampletones_core.instructions import ( + InstructionUnion, + NoiseInstruction, + PulseInstruction, + TriangleInstruction, +) +from sampletones_core.performance.song import song_instructions +from sampletones_core.project.patterns.channel import Channel +from sampletones_core.project.patterns.pattern import Pattern +from sampletones_core.project.patterns.row import Row +from sampletones_core.project.project import Project +from sampletones_core.project.settings import ProjectSettings +from sampletones_core.project.song import Song +from sampletones_core.project.voices.note_on import NoteOn +from sampletones_core.project.voices.sample import Sample +from tests.suite.performance import reconstruction_of + +TRANSPOSED_ROWS_PER_PATTERN: Final[int] = 16 +CONTOUR_VOLUME: Final[int] = 12 +CONTOUR_DUTY: Final[int] = 2 +CONTOURS: Final[Dict[ChannelName, Tuple[int, ...]]] = { + ChannelName.PULSE1: (69, 73, 76, 81), + ChannelName.TRIANGLE: (69, 76), + ChannelName.NOISE: (3, 4), +} +CONTOUR_TICKS: Final[Dict[ChannelName, int]] = { + ChannelName.PULSE1: 3, + ChannelName.TRIANGLE: 4, + ChannelName.NOISE: 5, +} +TRANSPOSED_CHANNELS: Final[Tuple[ChannelName, ...]] = tuple(CONTOURS) + + +def contour_instruction(channel_name: ChannelName, value: int) -> InstructionUnion: + """One sounding frame of a channel at a pitch, or at a period on noise.""" + match channel_name: + case ChannelName.NOISE: + return NoiseInstruction(on=True, period=value, volume=CONTOUR_VOLUME, short=False) + case ChannelName.TRIANGLE: + return TriangleInstruction(on=True, pitch=value) + case _: + return PulseInstruction(on=True, pitch=value, volume=CONTOUR_VOLUME, duty_cycle=CONTOUR_DUTY) + + +def contour_sample(channel_name: ChannelName, frames: int) -> Sample: + """A sample stepping through its channel's contour for ``frames`` frames, a few frames per step. + + A contour that keeps moving is what makes the step a note has reached audible, so a transpose + row placing its table at the wrong step shows up as a wrong pitch. + """ + contour = CONTOURS[channel_name] + hold = CONTOUR_TICKS[channel_name] + instructions = [contour_instruction(channel_name, contour[(tick // hold) % len(contour)]) for tick in range(frames)] + return Sample(name=f"Contour ({channel_name})", reconstruction=reconstruction_of(channel_name, instructions)) + + +def settling_sample(channel_name: ChannelName, frames: int) -> Sample: + """A sample stepping once through its channel's contour, then holding the last step for ``frames`` frames in all. + + Its arpeggio stops changing early while the note goes on sounding, so a tracker whose arpeggio + halts once it has played its items shows a transpose row landing after that. + """ + contour = CONTOURS[channel_name] + hold = CONTOUR_TICKS[channel_name] + instructions = [ + contour_instruction(channel_name, contour[min(tick // hold, len(contour) - 1)]) for tick in range(frames) + ] + return Sample(name=f"Settling ({channel_name})", reconstruction=reconstruction_of(channel_name, instructions)) + + +def flat_sample(channel_name: ChannelName, value: int, frames: int) -> Sample: + """A sample holding one pitch, or one period on noise, for ``frames`` frames.""" + instructions = [contour_instruction(channel_name, value)] * frames + return Sample(name=f"Flat ({channel_name})", reconstruction=reconstruction_of(channel_name, instructions)) + + +def rows_with(*cells: Tuple[int, Row]) -> List[Row]: + """A pattern's rows, blank apart from the ones given by their index.""" + rows = [Row() for _ in range(TRANSPOSED_ROWS_PER_PATTERN)] + for row_index, row in cells: + rows[row_index] = row + + return rows + + +def note(voice: Sample, transpose: Optional[int] = None) -> Row: + """A row starting ``voice``, at ``transpose`` where one is given.""" + return Row(command=NoteOn(voice_id=voice.id), transpose=transpose) + + +def one_channel_project( + voices: Sequence[Sample], + channel_name: ChannelName, + patterns: Mapping[int, List[Row]], + order: Sequence[Optional[int]], + *, + settings: ProjectSettings, +) -> Project: + """A project playing ``voices`` on one channel through the patterns ``order`` names.""" + channels = { + name: Channel( + name=name, + patterns={index: Pattern(rows=rows) for index, rows in patterns.items()} if name == channel_name else {}, + ) + for name in ChannelName.items() + } + project = Project.create(title="Transposes", author="Tester", settings=settings) + for voice in voices: + project.voices.append(voice) + + project.song = Song( + rows_per_pattern=TRANSPOSED_ROWS_PER_PATTERN, + order=[{channel_name: index} for index in order], + channels=channels, + ) + return project + + +def sounded_pitches(project: Project, channel_name: ChannelName) -> List[Optional[int]]: + """What the song's walk sounds on one channel each tick: the pitch, or on noise the period register. + + The noise register counts the periods from the fastest while the project counts them from the + slowest, so a period reaches the register as its complement. A tick the channel rests on is + ``None``. + """ + sounded: List[Optional[int]] = [] + for instruction in song_instructions(project)[channel_name]: + match instruction: + case PulseInstruction(): + sounded.append(instruction.pitch if instruction.on and instruction.volume else None) + case TriangleInstruction(): + sounded.append(instruction.pitch if instruction.on else None) + case NoiseInstruction(): + sounded.append(MAX_PERIOD - instruction.period if instruction.on and instruction.volume else None) + + return sounded diff --git a/tests/unit/sampletones_application/categories/test_skipped.py b/tests/unit/sampletones_application/categories/test_skipped.py index aee8cec9d..586207c3e 100644 --- a/tests/unit/sampletones_application/categories/test_skipped.py +++ b/tests/unit/sampletones_application/categories/test_skipped.py @@ -6,7 +6,7 @@ from sampletones_application.categories.skipped import MAX_REPORTED_ROWS, SkippedRowMessages from sampletones_application.paths import LANG_EN from sampletones_core.constants.enums import ChannelName -from sampletones_core.exporters.skipped import SkippedRow +from sampletones_core.exporters.skipped import SkippedRow, SkipReason from sampletones_core.project.voices.sample import Sample from sampletones_core.structures import IdentifiedCollection from tests.suite.sequencer import sample_reconstruction @@ -14,12 +14,19 @@ ROWS_LISTED: Final[int] = MAX_REPORTED_ROWS -def _skipped(voice_id: str, *, order_position: int = 3, row_index: int = 26) -> SkippedRow: +def _skipped( + voice_id: str, + *, + order_position: int = 3, + row_index: int = 26, + reason: SkipReason = SkipReason.NO_INSTRUMENT, +) -> SkippedRow: return SkippedRow( voice_id=voice_id, channel=ChannelName.PULSE1, order_position=order_position, row_index=row_index, + reason=reason, ) @@ -87,7 +94,7 @@ def test_the_report_opens_on_its_heading( notice = messages.notice((_skipped(voices[0].id),), voices) assert notice is not None - assert notice.splitlines()[0] == messages.heading + assert notice.splitlines()[0] == messages.headings[SkipReason.NO_INSTRUMENT] def test_a_short_list_is_printed_whole( self, @@ -116,3 +123,31 @@ def test_a_long_list_is_cut_and_says_how_many_it_leaves_out( lines: List[str] = notice.splitlines() assert len(lines) == 1 + ROWS_LISTED + 1 assert lines[-1] == f"and {left_out} more" + + +class TestTheReportGivesEachReasonAParagraph: + """A note cut and a transpose left out are different things for the reader to fix, so each reason + is reported under a heading of its own, the note cuts first. + """ + + def test_each_reason_opens_a_paragraph_of_its_own( + self, + messages: SkippedRowMessages, + voices: IdentifiedCollection[Sample], + ) -> None: + rows = ( + _skipped(voices[0].id, reason=SkipReason.UNREACHED_TRANSPOSE), + _skipped(voices[1].id), + ) + + notice = messages.notice(rows, voices) + + assert notice is not None + paragraphs = notice.split("\n\n") + assert [paragraph.splitlines()[0] for paragraph in paragraphs] == [ + messages.headings[SkipReason.NO_INSTRUMENT], + messages.headings[SkipReason.UNREACHED_TRANSPOSE], + ] + + def test_every_reason_has_a_heading(self, messages: SkippedRowMessages) -> None: + assert set(messages.headings) == set(SkipReason) diff --git a/tests/unit/sampletones_application/coordinators/export/test_nsf.py b/tests/unit/sampletones_application/coordinators/export/test_nsf.py index 879a951de..570d68468 100644 --- a/tests/unit/sampletones_application/coordinators/export/test_nsf.py +++ b/tests/unit/sampletones_application/coordinators/export/test_nsf.py @@ -14,7 +14,6 @@ from sampletones_application.view_model.shared.nsf.view import NSFExportViewModel from sampletones_core.constants.enums import ChannelName from sampletones_shared.paths.extensions import EXT_FILE_NSF -from sampletones_shared.types.callback import VoidCallback from tests.suite.language import FakeLanguageManager from tests.suite.nsf import FakeNSFExportService, FakeProgramBackend from tests.suite.player import player_sample @@ -75,11 +74,7 @@ def filters(self) -> List[FileFilter]: class NSFExportFixture: - """The coordinator over a real setup logic, a recording service and backend, and a recorded screen. - - The frame the hand-over waits for passes when a test asks for it, so the step from the dialog - leaving the screen to the run starting is walked one frame at a time. - """ + """The coordinator over a real setup logic, a recording service and backend, and a recorded screen.""" def __init__( self, @@ -106,14 +101,8 @@ def __init__( self.window = _WindowRecorder() self.save_dialog = _SaveDialogRecorder() self.activity = 0 - self.pending: List[VoidCallback] = [] monkeypatch.setattr(nsf_module, "save_file_dialog", self.save_dialog) - monkeypatch.setattr( - nsf_module.FrameCallbackManager, - "set_frame_callback", - lambda callback, frame_count=1: self.pending.append(callback), - ) self.coordinator = NSFExportCoordinator( self.logic, @@ -137,12 +126,6 @@ def export(self) -> None: def close(self) -> None: self.window.on_close() - def advance_frame(self) -> None: - pending = self.pending - self.pending = [] - for callback in pending: - callback() - @pytest.fixture def nsf(monkeypatch: pytest.MonkeyPatch) -> NSFExportFixture: @@ -226,22 +209,13 @@ def test_a_dismissed_dialog_leaves_the_file_alone(self, nsf: NSFExportFixture) - class TestHandingTheExportOver: - def test_the_dialog_leaves_the_screen_before_the_run_starts(self, nsf: NSFExportFixture) -> None: + def test_the_dialog_leaves_the_screen_and_the_run_starts(self, nsf: NSFExportFixture) -> None: nsf.coordinator.open_project() + destination = nsf.window.view.destination nsf.export() assert nsf.window.hides == 1 - assert not nsf.service.projects - assert nsf.coordinator.is_active - - def test_the_run_starts_once_that_frame_has_finished(self, nsf: NSFExportFixture) -> None: - nsf.coordinator.open_project() - destination = nsf.window.view.destination - nsf.export() - - nsf.advance_frame() - assert [run.destination for run in nsf.service.projects] == [destination] assert not nsf.coordinator.is_active assert nsf.activity == 2 @@ -252,6 +226,4 @@ def test_the_run_writes_the_program_the_dialog_stood_at(self, nsf: NSFExportFixt nsf.edit(view.choices.with_channel(ChannelName.NOISE, False, view.offer)) nsf.export() - nsf.advance_frame() - assert ChannelName.NOISE not in nsf.backend.program.channels diff --git a/tests/unit/sampletones_application/coordinators/tabs/conftest.py b/tests/unit/sampletones_application/coordinators/tabs/conftest.py new file mode 100644 index 000000000..c451c3838 --- /dev/null +++ b/tests/unit/sampletones_application/coordinators/tabs/conftest.py @@ -0,0 +1,3 @@ +from tests.suite.application import scheduling + +__all__ = ["scheduling"] diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_instructions.py b/tests/unit/sampletones_application/coordinators/tabs/test_instructions.py index 729487590..fdfd3f686 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_instructions.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_instructions.py @@ -7,6 +7,7 @@ from sampletones_application.coordinators.tabs.instructions import ( InstructionsTabCoordinator, ) +from sampletones_application.tags.general import TAG_GLOBAL_DIALOG_EXIT_CONFIRMATION from sampletones_application.tags.instructions import TAG_INSTRUCTIONS_LIBRARY_DIALOG_REBUILD_CONFIRMATION from sampletones_core.library import LibraryState from sampletones_shared.exceptions import LibraryDisplayError @@ -15,6 +16,8 @@ GENERATION_STATUS_TITLE_KEY: Final[str] = "instructions.library.title.generation_status_dialog" REMOVE_LIBRARY_MESSAGE_KEY: Final[str] = "instructions.library.message.remove_library_message" DISPLAY_ERROR_KEY: Final[str] = "instructions.library.message.status_display_error" +EXIT_LIBRARY_MESSAGE_KEY: Final[str] = "global.dialog.message.exit_library_generation_in_progress" +EXIT_LABEL_KEY: Final[str] = "global.dialog.label.exit" FRAME_CALLBACKS: Final[str] = "sampletones_application.coordinators.tabs.instructions.FrameCallbackManager" @@ -322,3 +325,34 @@ def test_unexpected_error_propagates(self) -> None: coordinator._on_instruction_loaded(MagicMock()) coordinator._dialogs.show_error.assert_not_called() + + +class TestTheExitAsksAboutALibraryBeingBuilt: + """Exiting stops a library being built, so the reader is asked first.""" + + def _coordinator(self, *, generating: bool) -> InstructionsTabCoordinator: + coordinator = _coordinator(LibraryState.CURRENT) + coordinator._library_logic.is_library_generating.return_value = generating + return coordinator + + def test_an_idle_library_lets_the_exit_go_on(self) -> None: + coordinator = self._coordinator(generating=False) + proceed = MagicMock() + + coordinator.guard_exit(proceed) + + proceed.assert_called_once_with() + coordinator._dialogs.show_confirmation.assert_not_called() + + def test_a_library_being_built_asks_first(self) -> None: + coordinator = self._coordinator(generating=True) + proceed = MagicMock() + + coordinator.guard_exit(proceed) + + proceed.assert_not_called() + args, kwargs = coordinator._dialogs.show_confirmation.call_args + assert args[0] == TAG_GLOBAL_DIALOG_EXIT_CONFIRMATION + assert args[1] == EXIT_LIBRARY_MESSAGE_KEY + assert args[3] is proceed + assert kwargs["ok_label"] == EXIT_LABEL_KEY diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_main.py b/tests/unit/sampletones_application/coordinators/tabs/test_main.py index a205303bc..cfa3f0c5f 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_main.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_main.py @@ -10,8 +10,10 @@ from sampletones_application.coordinators.tabs.main import MainTabCoordinator from sampletones_application.logic.main.converter.run import ConversionSuccess from sampletones_application.logic.main.sources.scan import FolderScan +from sampletones_application.tags.general import TAG_GLOBAL_DIALOG_EXIT_CONFIRMATION from sampletones_application.tags.main import ( TAG_MAIN_CONVERTER_DIALOG_CANCEL, + TAG_MAIN_CONVERTER_DIALOG_DISCARD_STEMS, TAG_MAIN_CONVERTER_DIALOG_LOAD, TAG_MAIN_CONVERTER_DIALOG_OVERWRITE_TARGET, TAG_MAIN_EXPLORER_DIALOG_CONVERTER_RUNNING, @@ -29,6 +31,8 @@ STOP_BUTTON_KEY: Final[str] = "main.converter.label.stop_button" CONTINUE_BUTTON_KEY: Final[str] = "main.converter.label.continue_button" NOTHING_BELOW_KEY: Final[str] = "main.converter.message.scan_nothing_below" +EXIT_CONVERSION_MESSAGE_KEY: Final[str] = "global.dialog.message.exit_conversion_in_progress" +EXIT_LABEL_KEY: Final[str] = "global.dialog.label.exit" def _hooks(*, operation_active: bool) -> MainTabHooks: @@ -452,6 +456,51 @@ def test_declining_converts_nothing(self, tmp_path: Path) -> None: coordinator._hooks.on_reconstruct_directory.assert_not_called() +class TestReconstructingAFileOverAGatheredList: + """Reconstruct on a file while recordings stand gathered asks about them first, and the + conversion it goes on to can ask about writing over a reconstruction in turn.""" + + @pytest.fixture(name="coordinator") + def coordinator_fixture(self, tmp_path: Path) -> MainTabCoordinator: + coordinator = _stems_coordinator(mixes=True, gathered=(Path("/audio/a.wav"),)) + coordinator._hooks.on_reconstruct_file.side_effect = lambda path: coordinator._confirm_overwriting_target( + (path.with_suffix(".stn"),) + ) + coordinator.request_reconstruct_file(tmp_path / "a.wav") + return coordinator + + def test_the_gathered_list_is_asked_about_first(self, coordinator: MainTabCoordinator) -> None: + assert coordinator._dialogs.show_confirmation.call_args.args[0] == TAG_MAIN_CONVERTER_DIALOG_DISCARD_STEMS + coordinator._hooks.on_reconstruct_file.assert_not_called() + + def test_replacing_the_list_reaches_the_conversion( + self, + coordinator: MainTabCoordinator, + tmp_path: Path, + ) -> None: + coordinator._dialogs.show_confirmation.call_args.args[3]() + + coordinator._hooks.on_reconstruct_file.assert_called_once_with(tmp_path / "a.wav") + + def test_the_conversion_asks_about_the_file_it_writes_over_next( + self, + coordinator: MainTabCoordinator, + tmp_path: Path, + ) -> None: + coordinator._dialogs.show_confirmation.call_args.args[3]() + + overwrite = coordinator._dialogs.show_confirmation.call_args + assert overwrite.args[0] == TAG_MAIN_CONVERTER_DIALOG_OVERWRITE_TARGET + assert overwrite.kwargs["path"] == tmp_path / "a.stn" + + def test_confirming_the_overwrite_starts_the_conversion(self, coordinator: MainTabCoordinator) -> None: + coordinator._dialogs.show_confirmation.call_args.args[3]() + + coordinator._dialogs.show_confirmation.call_args.args[3]() + + coordinator._converter_logic.start_conversion.assert_called_once_with(confirmed=True) + + def _rows_holding(*counts: int) -> Tuple[MagicMock, ...]: """Rows standing for that many recordings each, which is what the room is counted against.""" return tuple(MagicMock(recordings=tuple(MagicMock() for _ in range(count))) for count in counts) @@ -520,3 +569,34 @@ def test_a_busy_application_leaves_the_folder_alone(self, tmp_path: Path) -> Non coordinator._converter_logic.gather_folder.assert_not_called() coordinator._stem_selection_window.open.assert_not_called() + + +class TestTheExitAsksAboutARunningConversion: + """Exiting stops a running conversion, so the reader is asked first.""" + + def _coordinator(self, *, active: bool) -> MainTabCoordinator: + coordinator = _coordinator(operation_active=False) + coordinator._converter_logic.is_active = active + return coordinator + + def test_an_idle_converter_lets_the_exit_go_on(self) -> None: + coordinator = self._coordinator(active=False) + proceed = MagicMock() + + coordinator.guard_exit(proceed) + + proceed.assert_called_once_with() + coordinator._dialogs.show_confirmation.assert_not_called() + + def test_a_running_conversion_asks_first(self) -> None: + coordinator = self._coordinator(active=True) + proceed = MagicMock() + + coordinator.guard_exit(proceed) + + proceed.assert_not_called() + args, kwargs = coordinator._dialogs.show_confirmation.call_args + assert args[0] == TAG_GLOBAL_DIALOG_EXIT_CONFIRMATION + assert args[1] == EXIT_CONVERSION_MESSAGE_KEY + assert args[3] is proceed + assert kwargs["ok_label"] == EXIT_LABEL_KEY diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_reconstruction.py b/tests/unit/sampletones_application/coordinators/tabs/test_reconstruction.py index 0fc1b4d27..59d38938d 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_reconstruction.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_reconstruction.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Dict, Final +from typing import Dict, Final, List, Optional, Tuple from unittest.mock import MagicMock import pytest @@ -10,13 +10,35 @@ from sampletones_application.coordinators.tabs.reconstruction import ( ReconstructionTabCoordinator, ) +from sampletones_application.layout.behavior.scheduling.scheduling import SchedulingBehavior +from sampletones_application.logic.history.manager import HistoryManager +from sampletones_application.logic.project.controller import ProjectController +from sampletones_application.logic.project.manager import ProjectManager +from sampletones_application.logic.reconstruction.editor import InstrumentEditor +from sampletones_application.logic.reconstruction.instruments import ( + ReconstructionInstrumentsLogic, +) +from sampletones_application.logic.reconstruction.manager import ReconstructionManager +from sampletones_application.logic.reconstruction.rewrites.queue import ReconstructionRewrites +from sampletones_application.logic.reconstruction.rewrites.steps import RateChange, StemRemovalRequest from sampletones_application.paths import LANG_EN from sampletones_application.services.export.kind import ExportKind from sampletones_application.services.export.success import ExportSuccess -from sampletones_core.constants.enums import ChannelName +from sampletones_application.view_model.reconstruction.envelopes import ( + ChannelEnvelopesViewModel, +) +from sampletones_application.view_model.reconstruction.instruments import ( + ReconstructionInstrumentsViewModel, +) +from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.constants.general import SILENT_VOLUME from sampletones_core.exporters.skipped import NO_SKIPPED_ROWS from sampletones_core.exporters.truncation import EnvelopeTruncation from sampletones_core.exports.format import ExportFormat +from sampletones_core.features.envelope import Envelope +from sampletones_core.project.voices.creation import new_instrument +from sampletones_core.reconstructions import Reconstruction +from sampletones_core.reconstructions.reconstruction.stems.removal import without_stem from sampletones_shared.exceptions import ( DeserializationError, IncompatibleReconstructionVersionError, @@ -26,8 +48,16 @@ LoadReconstructionError, UnhandledReconstructionError, ) -from sampletones_shared.types.callback import VoidCallback from tests.suite.language import FakeLanguageManager +from tests.suite.regeneration import HeldRegeneration +from tests.suite.stems import ( + SHARED_CHANNEL, + SHARED_OWNERS, + SOLE_CHANNEL, + STEM_B_ID, + regenerated, + taking_turns, +) FILE_NOT_FOUND_KEY: Final[str] = "reconstructions.browser.message.file_not_found" LOAD_ERROR_KEY: Final[str] = "reconstructions.browser.message.load_error" @@ -40,6 +70,12 @@ REMOVE_DIRECTORY_MESSAGE_KEY: Final[str] = "reconstructions.browser.message.remove_directory_message" TEXTS: Final[Dict[str, str]] = {INCOMPATIBLE_VERSION_KEY: "got {} expected {}"} +HISTORY_BUDGET: Final[int] = 16 +TYPED_VOLUME: Final[Tuple[int, ...]] = (6, 6) +RETIMED_FREQUENCY: Final[int] = 50 +OPEN_VOICE_ID: Final[str] = "lead-id" + +__all__ = ["taking_turns"] @pytest.fixture @@ -188,12 +224,55 @@ def removal_coordinator() -> ReconstructionTabCoordinator: instance._browser_logic = MagicMock() instance._browser_panel = MagicMock() instance._reconstruction_manager = MagicMock() + instance._reconstruction_panel_logic = MagicMock() + instance._reconstruction_instruments_logic = MagicMock() instance._language_manager = FakeLanguageManager(TEXTS) instance._lbl_remove = "Remove" instance._msg_load_error = LOAD_ERROR_KEY return instance +class TestTheTabAsksForItsDocumentToChange: + """A removal the reader confirms and a rate the reader types reach the document as steps of its own.""" + + @pytest.fixture + def requests(self) -> MagicMock: + return MagicMock() + + @pytest.fixture + def coordinator(self, requests: MagicMock) -> ReconstructionTabCoordinator: + instance = object.__new__(ReconstructionTabCoordinator) + instance._dialogs = MagicMock() + instance._language_manager = FakeLanguageManager(TEXTS) + instance._lbl_remove = "Remove" + instance._reconstruction_stems_panel = MagicMock() + instance._on_rewrite_requested = requests + return instance + + def test_a_confirmed_removal_asks_for_the_recording_to_leave( + self, + coordinator: ReconstructionTabCoordinator, + requests: MagicMock, + ) -> None: + row = coordinator._reconstruction_stems_panel.stems_list.row.return_value + row.name = "b" + + coordinator._request_remove_stem(STEM_B_ID) + requests.assert_not_called() + coordinator._dialogs.show_confirmation.call_args.kwargs["on_confirm"]() + + requests.assert_called_once_with(StemRemovalRequest(stem_id=STEM_B_ID, stem_name="b")) + + def test_a_typed_rate_asks_for_the_document_to_be_re_timed( + self, + coordinator: ReconstructionTabCoordinator, + requests: MagicMock, + ) -> None: + coordinator._request_rate_change(RETIMED_FREQUENCY) + + requests.assert_called_once_with(RateChange(nes_frequency=RETIMED_FREQUENCY)) + + class TestRemoveTreeEntries: def test_request_remove_reconstruction_prompts_confirmation( self, @@ -215,15 +294,53 @@ def test_removing_open_reconstruction_detaches_and_marks_it_dirty( removal_coordinator: ReconstructionTabCoordinator, ) -> None: path = Path("tone.strec") - removal_coordinator._reconstruction_manager.filepath = path + removal_coordinator._reconstruction_manager.is_backed_by.return_value = True removal_coordinator._remove_reconstruction(path) + removal_coordinator._reconstruction_manager.is_backed_by.assert_called_once_with(path) removal_coordinator._reconstruction_manager.detach_current_reconstruction.assert_called_once_with() removal_coordinator._reconstruction_manager.mark_updated.assert_called_once_with() removal_coordinator._browser_logic.remove_path.assert_called_once_with(path) removal_coordinator._browser_panel.refresh.assert_called_once_with() + def test_the_open_document_is_shown_without_the_file_it_lost( + self, + removal_coordinator: ReconstructionTabCoordinator, + ) -> None: + """The Source card stops naming a file that is gone.""" + removal_coordinator._reconstruction_manager.is_backed_by.return_value = True + + removal_coordinator._remove_reconstruction(Path("tone.strec")) + + removal_coordinator._reconstruction_panel_logic.update_reconstruction.assert_called_once_with( + refit_waveform=False + ) + + def test_a_file_that_stays_keeps_the_open_document_on_it( + self, + removal_coordinator: ReconstructionTabCoordinator, + ) -> None: + removal_coordinator._reconstruction_manager.is_backed_by.return_value = True + removal_coordinator._browser_logic.remove_path.side_effect = OSError("busy") + + removal_coordinator._remove_reconstruction(Path("tone.strec")) + + removal_coordinator._reconstruction_manager.detach_current_reconstruction.assert_not_called() + removal_coordinator._reconstruction_manager.mark_updated.assert_not_called() + removal_coordinator._dialogs.show_error.assert_called_once() + + def test_removing_another_file_leaves_the_open_document_as_it_is( + self, + removal_coordinator: ReconstructionTabCoordinator, + ) -> None: + removal_coordinator._reconstruction_manager.is_backed_by.return_value = False + + removal_coordinator._remove_reconstruction(Path("other.strec")) + + removal_coordinator._reconstruction_manager.detach_current_reconstruction.assert_not_called() + removal_coordinator._browser_logic.remove_path.assert_called_once_with(Path("other.strec")) + def test_removing_open_directory_detaches_loaded_reconstruction_inside_it( self, removal_coordinator: ReconstructionTabCoordinator, @@ -252,18 +369,8 @@ def test_request_remove_directory_prompts_with_path( @pytest.fixture -def export_coordinator(monkeypatch: pytest.MonkeyPatch) -> ReconstructionTabCoordinator: - """A coordinator with only the collaborators ``_on_export_result`` touches. - - A report waits for the frame the export window leaves the screen in, so the wait is run - through at once and what the coordinator reports stays observable from the call that asks. - """ - - def run_now(callback: VoidCallback, frame_count: int = 1) -> None: - callback() - - monkeypatch.setattr(reconstruction_module.FrameCallbackManager, "set_frame_callback", run_now) - +def export_coordinator() -> ReconstructionTabCoordinator: + """A coordinator with only the collaborators ``_on_export_result`` touches.""" instance = object.__new__(ReconstructionTabCoordinator) instance._dialogs = MagicMock() instance._export_messages = ExportMessages.build(LanguageManager(LANG_EN)) @@ -420,3 +527,277 @@ def test_an_ordinary_call_asks_for_no_refit(self) -> None: coordinator.update_reconstruction() coordinator._reconstruction_panel_logic.update_reconstruction.assert_called_once_with(refit_waveform=False) + + def test_a_redraw_asks_for_no_refit(self) -> None: + coordinator = self._coordinator() + + coordinator.redraw_reconstruction(refit_waveform=False) + + coordinator._reconstruction_panel_logic.update_reconstruction.assert_called_once_with(refit_waveform=False) + + def test_a_redraw_at_another_rate_is_forwarded_to_the_panel_logic(self) -> None: + """An undo or a replacement can bring a document timed at another rate, which spans another length.""" + coordinator = self._coordinator() + + coordinator.redraw_reconstruction(refit_waveform=True) + + coordinator._reconstruction_panel_logic.update_reconstruction.assert_called_once_with(refit_waveform=True) + + +class TestTheInstrumentsPanelDrawsTheDocument: + """A document rewritten outside the instruments panel is drawn as it stands, and one the panel's + own edit rebuilt keeps the envelopes the panel draws.""" + + @pytest.fixture + def reconstruction(self, taking_turns: Reconstruction) -> Reconstruction: + return taking_turns + + @pytest.fixture + def reconstruction_manager( + self, + reconstruction: Reconstruction, + scheduling: SchedulingBehavior, + ) -> ReconstructionManager: + manager = ReconstructionManager(scheduling=scheduling) + manager.load_reconstruction_object(reconstruction, name="lead", voice_id=OPEN_VOICE_ID) + return manager + + @pytest.fixture + def instruments_logic( + self, + reconstruction_manager: ReconstructionManager, + ) -> ReconstructionInstrumentsLogic: + """The panel's logic over the editor the application builds, reading the open document.""" + controller = ProjectController(ProjectManager()) + editor = InstrumentEditor( + reconstruction_manager, + controller, + HistoryManager(controller, budget=HISTORY_BUDGET, strict=True), + lambda _voice_id, _feature_key: (), + ) + return ReconstructionInstrumentsLogic( + editor, + ReconstructionRewrites(reconstruction_manager, HeldRegeneration()), + ) + + @pytest.fixture + def drawn( + self, + instruments_logic: ReconstructionInstrumentsLogic, + ) -> List[Optional[ChannelEnvelopesViewModel]]: + """Every set of envelopes the panel is handed to draw.""" + drawn: List[Optional[ChannelEnvelopesViewModel]] = [] + instruments_logic.on_feature_data_changed = drawn.append + return drawn + + @pytest.fixture + def coordinator( + self, + instruments_logic: ReconstructionInstrumentsLogic, + ) -> ReconstructionTabCoordinator: + instance = object.__new__(ReconstructionTabCoordinator) + instance._reconstruction_panel_logic = MagicMock() + instance._reconstruction_instruments_logic = instruments_logic + return instance + + def test_a_removal_draws_the_document_it_leaves( + self, + coordinator: ReconstructionTabCoordinator, + reconstruction_manager: ReconstructionManager, + reconstruction: Reconstruction, + drawn: List[Optional[ChannelEnvelopesViewModel]], + ) -> None: + reconstruction_manager.apply_edited(without_stem(reconstruction, STEM_B_ID)) + + coordinator.redraw_reconstruction(refit_waveform=False) + + assert drawn == [reconstruction_manager.current_features] + + def test_a_removal_draws_the_frames_it_released_as_rests( + self, + coordinator: ReconstructionTabCoordinator, + reconstruction_manager: ReconstructionManager, + reconstruction: Reconstruction, + drawn: List[Optional[ChannelEnvelopesViewModel]], + ) -> None: + reconstruction_manager.apply_edited(without_stem(reconstruction, STEM_B_ID)) + + coordinator.redraw_reconstruction(refit_waveform=False) + + envelopes = drawn[-1] + assert envelopes is not None + assert envelopes[SHARED_CHANNEL].volume.items[SHARED_OWNERS.index(STEM_B_ID)] == SILENT_VOLUME + + def test_a_removal_draws_a_channel_it_emptied_standing_by( + self, + coordinator: ReconstructionTabCoordinator, + reconstruction_manager: ReconstructionManager, + reconstruction: Reconstruction, + drawn: List[Optional[ChannelEnvelopesViewModel]], + ) -> None: + reconstruction_manager.apply_edited(without_stem(reconstruction, STEM_B_ID)) + + coordinator.redraw_reconstruction(refit_waveform=False) + + envelopes = drawn[-1] + assert envelopes is not None + assert not envelopes[SOLE_CHANNEL].has_frames + + def test_a_regeneration_leaves_a_field_being_typed_in_alone( + self, + coordinator: ReconstructionTabCoordinator, + reconstruction_manager: ReconstructionManager, + reconstruction: Reconstruction, + drawn: List[Optional[ChannelEnvelopesViewModel]], + ) -> None: + """The regenerated document carries what the reader typed, so the panel keeps drawing it.""" + envelopes = reconstruction_manager.current_features + assert envelopes is not None + typed = envelopes[SHARED_CHANNEL].with_envelope(FeatureKey.VOLUME, Envelope[int](items=TYPED_VOLUME)) + reconstruction_manager.apply_edited(regenerated(reconstruction, SHARED_CHANNEL, typed)) + + coordinator.update_reconstruction() + + assert drawn == [] + + +class TestTheInstrumentsPanelFollowsTheInstrument: + """The panel keeps the instrument it edits while the project holds it, and draws a restore of it.""" + + @pytest.fixture + def project_controller(self) -> ProjectController: + return ProjectController(ProjectManager()) + + @pytest.fixture + def instrument_id(self, project_controller: ProjectController) -> str: + return project_controller.add_instrument(new_instrument("lead")).id + + @pytest.fixture + def editor(self, project_controller: ProjectController) -> InstrumentEditor: + reconstruction_manager = MagicMock(spec=ReconstructionManager) + reconstruction_manager.current_features = None + return InstrumentEditor( + reconstruction_manager, + project_controller, + HistoryManager(project_controller, budget=HISTORY_BUDGET, strict=True), + lambda _voice_id, _feature_key: (), + ) + + @pytest.fixture + def views(self) -> List[ReconstructionInstrumentsViewModel]: + """Every view the panel is handed to draw.""" + return [] + + @pytest.fixture + def coordinator( + self, + editor: InstrumentEditor, + views: List[ReconstructionInstrumentsViewModel], + ) -> ReconstructionTabCoordinator: + instruments_logic = ReconstructionInstrumentsLogic( + editor, ReconstructionRewrites(MagicMock(), HeldRegeneration()) + ) + instruments_logic.on_view_changed = views.append + instance = object.__new__(ReconstructionTabCoordinator) + instance._instrument_editor = editor + instance._reconstruction_instruments_logic = instruments_logic + instance._reconstruction_panel_logic = MagicMock() + return instance + + def test_a_restore_keeping_the_instrument_draws_it( + self, + coordinator: ReconstructionTabCoordinator, + editor: InstrumentEditor, + instrument_id: str, + views: List[ReconstructionInstrumentsViewModel], + ) -> None: + editor.edit_instrument(instrument_id) + + coordinator.follow_instrument(restored=True) + + assert len(views) == 1 + assert views[0].instrument is not None + assert editor.holds_instrument + + def test_another_change_keeping_the_instrument_leaves_the_panel_as_drawn( + self, + coordinator: ReconstructionTabCoordinator, + editor: InstrumentEditor, + instrument_id: str, + views: List[ReconstructionInstrumentsViewModel], + ) -> None: + """A keystroke in the tracker changes the project, and the panel keeps what the reader writes.""" + editor.edit_instrument(instrument_id) + + coordinator.follow_instrument(restored=False) + + assert views == [] + assert editor.holds_instrument + + @pytest.mark.parametrize("restored", (True, False), ids=("restored", "edited")) + def test_an_instrument_the_project_lost_leaves_the_panel_empty( + self, + restored: bool, + coordinator: ReconstructionTabCoordinator, + editor: InstrumentEditor, + project_controller: ProjectController, + instrument_id: str, + views: List[ReconstructionInstrumentsViewModel], + ) -> None: + editor.edit_instrument(instrument_id) + project_controller.remove_voice(instrument_id) + + coordinator.follow_instrument(restored=restored) + + assert not editor.holds_instrument + assert len(views) == 1 + assert views[0].instrument is None + coordinator._reconstruction_panel_logic.close_reconstruction.assert_called_once_with() + + def test_a_panel_holding_no_instrument_follows_nothing( + self, + coordinator: ReconstructionTabCoordinator, + views: List[ReconstructionInstrumentsViewModel], + ) -> None: + coordinator.follow_instrument(restored=True) + + assert views == [] + + def test_closing_the_instrument_draws_the_panel_empty( + self, + coordinator: ReconstructionTabCoordinator, + editor: InstrumentEditor, + instrument_id: str, + views: List[ReconstructionInstrumentsViewModel], + ) -> None: + editor.edit_instrument(instrument_id) + + coordinator.close_instrument() + + assert not editor.holds_instrument + assert len(views) == 1 + assert views[0].instrument is None + + def test_closing_the_instrument_empties_the_waveform_card( + self, + coordinator: ReconstructionTabCoordinator, + editor: InstrumentEditor, + instrument_id: str, + ) -> None: + """The card draws the instrument's own audio, so the tab empties it the way a closed reconstruction does.""" + editor.edit_instrument(instrument_id) + + coordinator.close_instrument() + + coordinator._reconstruction_panel_logic.close_reconstruction.assert_called_once_with() + + def test_closing_with_no_instrument_draws_nothing( + self, + coordinator: ReconstructionTabCoordinator, + views: List[ReconstructionInstrumentsViewModel], + ) -> None: + """A reconstruction the panel draws stays drawn, since the close reaches an instrument alone.""" + coordinator.close_instrument() + + assert views == [] + coordinator._reconstruction_panel_logic.close_reconstruction.assert_not_called() diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py index f430990c4..c9fc784fb 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py @@ -1,7 +1,7 @@ from datetime import UTC, datetime from pathlib import Path from typing import Dict, Final, List, Optional -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import pytest @@ -70,11 +70,14 @@ InvalidReconstructionValuesError, MalformedInstrumentError, ) +from tests.suite.gates import HeldGate, held_gate from tests.suite.language import FakeLanguageManager FREQUENCY_MISMATCH_MESSAGE_KEY: Final[str] = "global.dialog.message.frequency_mismatch" REMOVE_VOICE_MESSAGE_KEY: Final[str] = "global.dialog.message.remove_voice" +__all__ = ["held_gate"] + TEXTS: Final[Dict[str, str]] = { FREQUENCY_MISMATCH_MESSAGE_KEY: "recon {reconstruction} vs project {project}", REMOVE_VOICE_MESSAGE_KEY: "Remove {name}?", @@ -1013,11 +1016,11 @@ def test_detail_reads_the_sample_before_it_is_substituted( assert [call[0] for call in order.mock_calls] == ["detail", "replace"] replace_coordinator._history_detail.replace_sample.assert_called_once_with("bass-id", "kick_02") - def test_replacement_is_announced_before_the_substitution( + def test_replacement_is_announced_after_the_substitution( self, replace_coordinator: SequencerReconstructions, ) -> None: - """An editor holding the sample open identifies it by the reconstruction the swap replaces.""" + """An editor holding the sample open by its id shows the reconstruction in the form the project keeps.""" reconstruction = replace_coordinator._browser_logic.load_reconstruction.return_value order = MagicMock() order.attach_mock(replace_coordinator._on_sample_reconstruction_replaced, "announce") @@ -1028,7 +1031,7 @@ def test_replacement_is_announced_before_the_substitution( replace_coordinator.replace_from_file(Path("kick_02.stn")) - assert [call[0] for call in order.mock_calls] == ["announce", "replace"] + assert [call[0] for call in order.mock_calls] == ["replace", "announce"] replace_coordinator._on_sample_reconstruction_replaced.assert_called_once_with( "bass-id", reconstruction, @@ -1084,10 +1087,15 @@ def test_label_is_absent_without_a_selection( @pytest.fixture -def history_coordinator() -> SequencerTabCoordinator: - """A coordinator whose history is a mock, so a test reads what a delegation asked of it.""" +def history_coordinator(held_gate: HeldGate) -> SequencerTabCoordinator: + """A coordinator whose history is a mock, so a test reads what a delegation asked of it. + + The edits of the open reconstruction hold every gesture until the case releases the gate. + """ instance = object.__new__(SequencerTabCoordinator) instance._history = MagicMock() + instance._after_edits = held_gate + instance._reconstructions = MagicMock() return instance @@ -1115,7 +1123,7 @@ def wired_history_coordinator( A real manager observes a real controller, and every project replacement — including the ones undo/redo drive — routes back through - ``_on_project_replaced``, exactly as ``_wire_callbacks`` sets it up. The + ``realign_with_project``, exactly as the application's fan-out reaches it. The channels logic is real too, since the handler decides its lifetime. The panel-refreshing ``refresh`` is stubbed since no GUI subtree exists here. """ @@ -1123,9 +1131,10 @@ def wired_history_coordinator( controller = ProjectController(ProjectManager()) history = HistoryManager(controller, budget=10, strict=True) controller.on_mutation = history.handle_mutation - controller.on_project_replaced = instance._on_project_replaced + controller.on_project_replaced = instance.realign_with_project instance._project_controller = controller instance._history = history + instance._after_edits = lambda gesture: gesture() instance._sequencer_channels_logic = SequencerChannelsLogic() instance._sequencer_channels_logic.on_channels_changed = lambda _: None monkeypatch.setattr(instance, "refresh", MagicMock()) @@ -1187,7 +1196,7 @@ class TestChannelMuteLifetime: """The mute set spans history navigation and starts fresh on a document transition. Both arrive as the controller's single ``on_project_replaced`` signal, so these pin the - distinction ``_on_project_replaced`` draws from ``HistoryManager.is_restoring``. + distinction ``realign_with_project`` draws from ``HistoryManager.is_restoring``. """ def test_undo_keeps_the_mute_set( @@ -1497,21 +1506,97 @@ def test_a_table_click_tells_the_menu_bar( class TestHistoryDelegation: - def test_undo_delegates_to_history(self, history_coordinator: SequencerTabCoordinator) -> None: + """Undo, redo and a jump reach the history once the edits of the open reconstruction before them have landed.""" + + def test_undo_delegates_to_history(self, history_coordinator: SequencerTabCoordinator, held_gate: HeldGate) -> None: history_coordinator.undo() + history_coordinator._history.undo.assert_not_called() + + held_gate.release() history_coordinator._history.undo.assert_called_once_with() - def test_redo_delegates_to_history(self, history_coordinator: SequencerTabCoordinator) -> None: + def test_redo_delegates_to_history(self, history_coordinator: SequencerTabCoordinator, held_gate: HeldGate) -> None: history_coordinator.redo() + history_coordinator._history.redo.assert_not_called() + + held_gate.release() history_coordinator._history.redo.assert_called_once_with() - def test_jump_delegates_to_history(self, history_coordinator: SequencerTabCoordinator) -> None: + def test_jump_delegates_to_history(self, history_coordinator: SequencerTabCoordinator, held_gate: HeldGate) -> None: history_coordinator.jump_to_history(3) + history_coordinator._history.jump_to.assert_not_called() + + held_gate.release() history_coordinator._history.jump_to.assert_called_once_with(3) + def test_replacing_a_sample_waits_for_the_edits_before_it( + self, + history_coordinator: SequencerTabCoordinator, + held_gate: HeldGate, + ) -> None: + """The sample may be the one open on the Reconstructions tab.""" + path = Path("incoming.stn") + history_coordinator.replace_reconstruction(path) + history_coordinator._reconstructions.replace_from_file.assert_not_called() + + held_gate.release() + + history_coordinator._reconstructions.replace_from_file.assert_called_once_with(path) + + def test_a_voice_gesture_reading_the_open_sample_waits_for_the_edits_before_it( + self, + history_coordinator: SequencerTabCoordinator, + held_gate: HeldGate, + ) -> None: + """A copy, an export or an instrument taken from the sample reads what the reader drew on it.""" + for name in ( + "_sequencer_voices_logic", + "_sequencer_voices_panel", + "_recorder", + "_history_detail", + "_voices", + "_instrument_exports", + ): + setattr(history_coordinator, name, MagicMock()) + duplicate = history_coordinator._recorder.undoable.return_value + with patch.object(history_coordinator, "add_instrument_from_channel") as instrument_from_channel: + history_coordinator._wire_voices_callbacks() + panel = history_coordinator._sequencer_voices_panel + panel.on_duplicate_requested("lead") + panel.on_export_instrument_requested("lead", ChannelName.PULSE1) + panel.on_instrument_from_channel_requested("lead", ChannelName.TRIANGLE) + duplicate.assert_not_called() + history_coordinator._instrument_exports.request_voice.assert_not_called() + instrument_from_channel.assert_not_called() + + held_gate.release() + + duplicate.assert_called_once_with("lead") + history_coordinator._instrument_exports.request_voice.assert_called_once_with("lead", ChannelName.PULSE1) + instrument_from_channel.assert_called_once_with("lead", ChannelName.TRIANGLE) + + def test_a_new_nes_frequency_waits_for_the_edits_before_it( + self, + history_coordinator: SequencerTabCoordinator, + held_gate: HeldGate, + ) -> None: + """The rate re-times the sample open on the Reconstructions tab, so it follows the edits made there.""" + history_coordinator._sequencer_module_panel = MagicMock() + history_coordinator._recorder = MagicMock() + history_coordinator._sequencer_tracker_logic = MagicMock() + history_coordinator._history_detail = MagicMock() + with patch.object(history_coordinator, "_request_nes_frequency_change") as request: + history_coordinator._wire_module_callbacks() + history_coordinator._sequencer_module_panel.on_nes_frequency(50) + request.assert_not_called() + + held_gate.release() + + request.assert_called_once_with(50) + class TestUndoableWrapper: def test_wrapped_call_runs_inside_a_transaction(self, recorder: SequencerHistoryRecorder) -> None: diff --git a/tests/unit/sampletones_application/coordinators/test_project.py b/tests/unit/sampletones_application/coordinators/test_project.py index 3ac99e5da..1719a2e13 100644 --- a/tests/unit/sampletones_application/coordinators/test_project.py +++ b/tests/unit/sampletones_application/coordinators/test_project.py @@ -12,7 +12,8 @@ from sampletones_application.paths import LANG_EN from sampletones_application.services.export.kind import ExportKind from sampletones_application.services.export.success import ExportSuccess -from sampletones_core.exporters.skipped import NO_SKIPPED_ROWS, SkippedRow +from sampletones_application.utils.gui.dialogs.outcome import SaveOutcome +from sampletones_core.exporters.skipped import NO_SKIPPED_ROWS, SkippedRow, SkipReason from sampletones_core.exporters.truncation import EnvelopeTruncation from sampletones_core.exports.format import ExportFormat from sampletones_core.project.project import Project @@ -237,13 +238,8 @@ def test_a_closed_project_opens_nothing( @pytest.fixture(name="coordinator") -def coordinator_fixture(monkeypatch: pytest.MonkeyPatch) -> ProjectCoordinator: - """A coordinator reporting a finished export at once, in the shipped language.""" - monkeypatch.setattr( - project_module.FrameCallbackManager, - "set_frame_callback", - lambda callback: callback(), - ) +def coordinator_fixture() -> ProjectCoordinator: + """A coordinator reporting a finished export, in the shipped language.""" project_manager = MagicMock() project_manager.current = Project.create(title="Demo", author="Tester", settings=ProjectSettings()) return ProjectCoordinator( @@ -287,6 +283,7 @@ def _row(index: int) -> SkippedRow: channel=SILENT_CHANNEL, order_position=3, row_index=index, + reason=SkipReason.NO_INSTRUMENT, ) def test_a_project_with_an_instrument_for_every_row_announces_the_export_alone( @@ -374,6 +371,7 @@ def test_the_instruments_shortened_follow_the_rows_left_silent( channel=SILENT_CHANNEL, order_position=0, row_index=0, + reason=SkipReason.NO_INSTRUMENT, ) truncation = EnvelopeTruncation(frames=512, source_frames=600, instruments=1) @@ -387,3 +385,126 @@ def test_the_instruments_shortened_follow_the_rows_left_silent( source_frames=truncation.source_frames, instruments=truncation.instruments, ) + + +class TestTheSaveAPromptWaitsOn: + """A save prompt goes on, asks again or stands aside according to what the save came to.""" + + @pytest.fixture(name="save_dialog") + def save_dialog_fixture(self, monkeypatch: pytest.MonkeyPatch) -> MagicMock: + dialog = MagicMock(return_value=None) + monkeypatch.setattr(project_module, "save_file_dialog", dialog) + return dialog + + @pytest.fixture(name="saving") + def saving_fixture( + self, + project_coordinator: ProjectCoordinator, + tmp_path: Path, + ) -> ProjectCoordinator: + project_coordinator._session_manager.get_project_path.return_value = tmp_path + return project_coordinator + + def test_a_project_with_a_file_is_written_there( + self, + saving: ProjectCoordinator, + tmp_path: Path, + ) -> None: + filepath = tmp_path / "song.stp" + saving._session_manager.current_project = filepath + + assert saving.save() is SaveOutcome.WRITTEN + saving._project_controller.save.assert_called_once_with(filepath) + + def test_a_file_dialog_closed_without_a_name_calls_the_save_off( + self, + saving: ProjectCoordinator, + save_dialog: MagicMock, + ) -> None: + saving._session_manager.current_project = None + + assert saving.save() is SaveOutcome.CALLED_OFF + saving._project_controller.save.assert_not_called() + + def test_a_name_chosen_in_the_file_dialog_is_written( + self, + saving: ProjectCoordinator, + save_dialog: MagicMock, + tmp_path: Path, + ) -> None: + filepath = tmp_path / "song.stp" + save_dialog.return_value = filepath + saving._session_manager.current_project = None + + assert saving.save() is SaveOutcome.WRITTEN + saving._project_controller.save.assert_called_once_with(filepath) + + def test_a_write_that_fails_shows_its_error( + self, + saving: ProjectCoordinator, + tmp_path: Path, + ) -> None: + failure = OSError("disk full") + saving._session_manager.current_project = tmp_path / "song.stp" + saving._project_controller.save.side_effect = failure + + assert saving.save() is SaveOutcome.FAILED + assert saving._dialogs.show_error.call_args.args[0] is failure + + def test_a_save_asked_for_by_itself_says_it_landed( + self, + saving: ProjectCoordinator, + tmp_path: Path, + ) -> None: + saving._session_manager.current_project = tmp_path / "song.stp" + + saving.save() + + saving._dialogs.show_info.assert_called_once() + + def test_a_save_a_prompt_asked_for_goes_on_without_a_word( + self, + saving: ProjectCoordinator, + tmp_path: Path, + ) -> None: + """What the prompt guards opens next, so it opens alone.""" + filepath = tmp_path / "song.stp" + saving._session_manager.current_project = filepath + + assert saving._write_project() is SaveOutcome.WRITTEN + saving._project_controller.save.assert_called_once_with(filepath) + saving._dialogs.show_info.assert_not_called() + + def test_every_save_prompt_waits_on_the_quiet_save(self, saving: ProjectCoordinator) -> None: + saving._project_controller.is_open = True + saving._project_controller.is_dirty = True + + saving.close_with_confirmation() + saving.new_project_with_confirmation() + + for prompt in saving._dialogs.show_save_confirmation.call_args_list: + assert prompt.kwargs["on_save"] == saving._write_project + + +class TestTheExitAsksAboutTheProject: + """Exiting with unsaved project changes asks to save them first, and the answer lets the exit go on.""" + + def test_a_saved_project_lets_the_exit_go_on(self, project_coordinator: ProjectCoordinator) -> None: + project_coordinator._project_controller.is_dirty = False + proceed = MagicMock() + + project_coordinator.guard_exit(proceed) + + proceed.assert_called_once_with() + project_coordinator._dialogs.show_save_confirmation.assert_not_called() + + def test_an_unsaved_project_asks_to_save_first(self, project_coordinator: ProjectCoordinator) -> None: + project_coordinator._project_controller.is_dirty = True + proceed = MagicMock() + + project_coordinator.guard_exit(proceed) + + proceed.assert_not_called() + prompt = project_coordinator._dialogs.show_save_confirmation.call_args.kwargs + assert prompt["on_save"] == project_coordinator._write_project + assert prompt["on_confirm"] is proceed diff --git a/tests/unit/sampletones_application/coordinators/test_reconstruction.py b/tests/unit/sampletones_application/coordinators/test_reconstruction.py index 3e505776f..fe5051600 100644 --- a/tests/unit/sampletones_application/coordinators/test_reconstruction.py +++ b/tests/unit/sampletones_application/coordinators/test_reconstruction.py @@ -1,24 +1,130 @@ from dataclasses import dataclass +from enum import Enum from pathlib import Path -from typing import List, Optional -from unittest.mock import MagicMock +from typing import Final, List, Optional, Tuple +from unittest.mock import MagicMock, call, patch import pytest +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.coordinators import reconstruction as reconstruction_module from sampletones_application.coordinators.reconstruction import ReconstructionCoordinator -from sampletones_application.logic.reconstruction.edit import StemRemoval +from sampletones_application.coordinators.tabs.reconstruction import ( + ReconstructionTabCoordinator, +) +from sampletones_application.layout.behavior.scheduling.scheduling import SchedulingBehavior +from sampletones_application.logic.history.action import HistoryAction +from sampletones_application.logic.history.manager import HistoryManager +from sampletones_application.logic.project.controller import ProjectController +from sampletones_application.logic.project.manager import ProjectManager +from sampletones_application.logic.reconstruction.edit import ChannelEdit, ReconstructionEdit, Retune, StemRemoval from sampletones_application.logic.reconstruction.manager import ReconstructionManager -from sampletones_application.services.regeneration.service import RegeneratedInstrument +from sampletones_application.logic.reconstruction.rewrites.queue import ReconstructionRewrites +from sampletones_application.logic.reconstruction.rewrites.steps import ( + ChannelChange, + RateChange, + StemRemovalRequest, +) +from sampletones_application.services.regeneration.service import RegenerationService from sampletones_application.services.result import ServiceSuccess +from sampletones_application.tags.general import TAG_GLOBAL_DIALOG_RECONSTRUCTION_REPLACED +from sampletones_application.utils.gui.dialogs.outcome import SaveOutcome from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.constants.general import SILENT_VOLUME +from sampletones_core.exporters import CHANNEL_TO_EXPORTER_MAP +from sampletones_core.features.envelope import Envelope +from sampletones_core.project.voices.creation import new_instrument +from sampletones_core.project.voices.sample import Sample from sampletones_core.reconstructions import Reconstruction +from sampletones_core.reconstructions.reconstruction.stems.removal import without_stem from sampletones_shared.exceptions import ( InvalidMetadataError, InvalidReconstructionValuesError, ) +from sampletones_shared.paths.extensions import EXT_FILE_PROJECT from tests.conftest import ReconstructionFactory +from tests.suite.application import HeldQueue, held_queue, scheduling, synchronous_executor from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase +from tests.suite.language import FakeLanguageManager +from tests.suite.stems import ( + SHARED_CHANNEL, + SHARED_OWNERS, + SOLE_CHANNEL, + STEM_A_ID, + STEM_B_ID, + TAKING_TURNS_PITCH, + TAKING_TURNS_VOLUME, + taking_turns, + taking_turns_file, +) + +__all__ = ["held_queue", "scheduling", "synchronous_executor", "taking_turns", "taking_turns_file"] + +OPEN_VOICE_ID: Final[str] = "lead-id" +HISTORY_BUDGET: Final[int] = 16 +EDITED_VOLUME: Final[Tuple[int, ...]] = (5, 5) +LATER_VOLUME: Final[Tuple[int, ...]] = (3, 3) +SOLE_VOLUME: Final[Tuple[int, ...]] = (3,) +ARPEGGIO: Final[Tuple[int, ...]] = (0, 3) +RETUNED_FREQUENCY: Final[int] = 50 +REMOVED_NAME: Final[str] = "b" + + +def _volumes(reconstruction: Reconstruction, channel_name: ChannelName) -> List[int]: + """The volume each frame of a channel plays, as the document holds it.""" + return [instruction.volume for instruction in reconstruction.instructions[channel_name]] + + +def _sounding_volumes(reconstruction: Reconstruction, channel_name: ChannelName) -> List[int]: + """The volume of each frame a channel sounds, leaving out the release an envelope ends on.""" + return [instruction.volume for instruction in reconstruction.instructions[channel_name] if instruction.on] + + +def _sounding_pitches(reconstruction: Reconstruction, channel_name: ChannelName) -> List[int]: + return [instruction.pitch for instruction in reconstruction.instructions[channel_name] if instruction.on] + + +def _move( + coordinator: ReconstructionCoordinator, + channel_name: ChannelName, + feature_key: FeatureKey, + items: Tuple[int, ...], +) -> None: + """The reader moving one dimension of a channel on the instruments panel.""" + coordinator.request_rewrite( + ChannelChange( + channel_name=channel_name, + feature_key=feature_key, + envelopes={feature_key: Envelope[int](items=items)}, + initial_pitch=None, + ) + ) + + +def _remove(coordinator: ReconstructionCoordinator, stem_id: int) -> None: + """The reader confirming the removal of a recording on the stems card.""" + coordinator.request_rewrite(StemRemovalRequest(stem_id=stem_id, stem_name=REMOVED_NAME)) + + +def _retime(coordinator: ReconstructionCoordinator, nes_frequency: int) -> None: + """The reader typing another rate into the Audio source panel.""" + coordinator.request_rewrite(RateChange(nes_frequency=nes_frequency)) + + +def _undo(coordinator: ReconstructionCoordinator, history: HistoryManager) -> None: + """The reader pressing Undo, which the application holds until the edits before it land.""" + coordinator.after_edits(history.undo) + + +def _save(coordinator: ReconstructionCoordinator) -> None: + """The reader pressing Save reconstruction, which the application holds until the edits before it land.""" + coordinator.after_edits(coordinator.save) + + +class VoiceKind(Enum): + SAMPLE = "sample" + INSTRUMENT = "instrument" @pytest.fixture @@ -28,12 +134,13 @@ def reconstruction_coordinator() -> ReconstructionCoordinator: MagicMock(), MagicMock(), MagicMock(), + MagicMock(), + MagicMock(), dialogs=MagicMock(), language_manager=MagicMock(), on_tab_switch=MagicMock(), on_session_state_changed=MagicMock(), on_reconstruction_updated=MagicMock(), - is_reconstruction_embedded=MagicMock(return_value=False), ) @@ -47,14 +154,16 @@ def _gating_coordinator( MagicMock(), MagicMock(), MagicMock(), + MagicMock(), + MagicMock(), dialogs=MagicMock(), language_manager=MagicMock(), on_tab_switch=MagicMock(), on_session_state_changed=MagicMock(), on_reconstruction_updated=MagicMock(), - is_reconstruction_embedded=lambda: embedded, ) coordinator._reconstruction_manager.session.unsaved_changes = unsaved + coordinator._reconstruction_manager.is_project_sample = embedded coordinator.set_reconstructions_tab(MagicMock()) return coordinator @@ -114,23 +223,32 @@ def test_restore_clears_session_pointer( ) -class TestRegenerationApplyOrdering: - def test_history_hook_sees_prior_reconstruction_identity( - self, - reconstruction_factory: ReconstructionFactory, - ) -> None: - """Pins the hook-before-apply order in ``apply_edit``. +class TestAnEditIsRecordedBeforeItIsShown: + """Pins the hook-before-apply order in ``apply_edit``, which every landing edit takes. - The hook locates the owning project sample by identity against the prior - reconstruction, so it must observe the manager before the document rebinds - to the regenerated object. - """ - manager = ReconstructionManager(scheduling=MagicMock()) - prior = reconstruction_factory() - manager.load_reconstruction_object(prior, name="lead") - observed: List[Optional[Reconstruction]] = [] + The hook records the edit against the project, so the history holds it by the time the open + document rebinds to the new object and the tab shows it. + """ + + @pytest.fixture + def observed(self) -> List[Tuple[Optional[Reconstruction], Reconstruction]]: + """The document open as each edit is recorded, beside the reconstruction the edit brings.""" + return [] + + @pytest.fixture + def recording_coordinator( + self, + reconstruction_manager: ReconstructionManager, + rewrites: ReconstructionRewrites, + observed: List[Tuple[Optional[Reconstruction], Reconstruction]], + held_queue: HeldQueue, + taking_turns: Reconstruction, + ) -> ReconstructionCoordinator: + reconstruction_manager.load_reconstruction_object(taking_turns, name="lead", voice_id=OPEN_VOICE_ID) coordinator = ReconstructionCoordinator( - manager, + reconstruction_manager, + MagicMock(), + rewrites, MagicMock(), MagicMock(), MagicMock(), @@ -138,53 +256,137 @@ def test_history_hook_sees_prior_reconstruction_identity( language_manager=MagicMock(), on_tab_switch=MagicMock(), on_session_state_changed=MagicMock(), - on_reconstruction_updated=lambda _outcome: observed.append(manager.reconstruction), - is_reconstruction_embedded=lambda: False, - ) - coordinator.set_reconstructions_tab(MagicMock()) - regenerated = reconstruction_factory() - outcome = RegeneratedInstrument( - reconstruction=regenerated, - channel_name=ChannelName.PULSE1, - feature_key=FeatureKey.VOLUME, + on_reconstruction_updated=lambda edit: observed.append( + (reconstruction_manager.reconstruction, edit.reconstruction) + ), ) + coordinator.set_reconstructions_tab(MagicMock(spec=ReconstructionTabCoordinator)) + return coordinator - coordinator._on_regeneration_result(ServiceSuccess(value=outcome)) + def test_a_regenerated_instrument_is_recorded_against_the_document_it_leaves( + self, + recording_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + observed: List[Tuple[Optional[Reconstruction], Reconstruction]], + held_queue: HeldQueue, + taking_turns: Reconstruction, + ) -> None: + _move(recording_coordinator, SHARED_CHANNEL, FeatureKey.VOLUME, EDITED_VOLUME) + held_queue.drain() assert len(observed) == 1 - assert observed[0] is prior - assert manager.reconstruction is regenerated + prior, edited = observed[0] + assert prior is taking_turns + assert reconstruction_manager.reconstruction is edited - -class TestStemRemovalApplyOrdering: def test_a_removed_recording_travels_the_same_path_as_a_regenerated_instrument( self, - reconstruction_factory: ReconstructionFactory, + recording_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + observed: List[Tuple[Optional[Reconstruction], Reconstruction]], + taking_turns: Reconstruction, ) -> None: """Every edit of the open document is applied alike, so the history sees them alike.""" - manager = ReconstructionManager(scheduling=MagicMock()) - prior = reconstruction_factory() - manager.load_reconstruction_object(prior, name="lead") - observed: List[Optional[Reconstruction]] = [] - coordinator = ReconstructionCoordinator( - manager, - MagicMock(), - MagicMock(), - MagicMock(), - dialogs=MagicMock(), - language_manager=MagicMock(), - on_tab_switch=MagicMock(), - on_session_state_changed=MagicMock(), - on_reconstruction_updated=lambda _edit: observed.append(manager.reconstruction), - is_reconstruction_embedded=lambda: False, + _remove(recording_coordinator, STEM_B_ID) + + assert len(observed) == 1 + prior, remaining = observed[0] + assert prior is taking_turns + assert reconstruction_manager.reconstruction is remaining + + +class TestAnEditRedrawsWhatItRewrote: + """The instruments panel keeps what its own edit drew, and draws afresh what a removal rewrote.""" + + @pytest.fixture + def tab(self, reconstruction_coordinator: ReconstructionCoordinator) -> MagicMock: + tab = MagicMock(spec=ReconstructionTabCoordinator) + reconstruction_coordinator.set_reconstructions_tab(tab) + return tab + + def test_a_regenerated_instrument_keeps_the_envelopes_the_panel_draws( + self, + reconstruction_coordinator: ReconstructionCoordinator, + tab: MagicMock, + reconstruction_factory: ReconstructionFactory, + ) -> None: + """The regeneration carries what the reader typed, so a field being typed in keeps its text.""" + reconstruction_coordinator.apply_edit( + ChannelEdit( + reconstruction=reconstruction_factory(), + channel_name=ChannelName.PULSE1, + feature_key=FeatureKey.VOLUME, + ) ) - coordinator.set_reconstructions_tab(MagicMock()) - remaining = reconstruction_factory() - coordinator.apply_edit(StemRemoval(reconstruction=remaining, stem_name="kick")) + tab.update_reconstruction.assert_called_once_with() + tab.redraw_reconstruction.assert_not_called() + + def test_a_retune_keeps_the_envelopes_and_refits_the_waveform( + self, + reconstruction_coordinator: ReconstructionCoordinator, + tab: MagicMock, + reconstruction_factory: ReconstructionFactory, + ) -> None: + """A retune carries every instruction over, and its audio spans another length.""" + reconstruction_coordinator.apply_edit( + Retune( + reconstruction=reconstruction_factory(), + nes_frequency=RETUNED_FREQUENCY, + ) + ) + + tab.update_reconstruction.assert_called_once_with(refit_waveform=True) + tab.redraw_reconstruction.assert_not_called() + + def test_a_removed_recording_redraws_the_instruments_panel( + self, + reconstruction_coordinator: ReconstructionCoordinator, + tab: MagicMock, + reconstruction_factory: ReconstructionFactory, + ) -> None: + """A removal releases frames the panel drew as sounding, so the panel draws the document it leaves.""" + reconstruction_coordinator.apply_edit( + StemRemoval( + reconstruction=reconstruction_factory(), + stem_name="kick", + ) + ) + + tab.redraw_reconstruction.assert_called_once_with(refit_waveform=False) + tab.update_reconstruction.assert_not_called() + + @pytest.mark.parametrize("edit_kind", ("channel", "retune", "removal")) + def test_every_edit_leaves_the_document_unsaved( + self, + edit_kind: str, + reconstruction_coordinator: ReconstructionCoordinator, + tab: MagicMock, + reconstruction_factory: ReconstructionFactory, + ) -> None: + edits = { + "channel": ChannelEdit( + reconstruction=reconstruction_factory(), + channel_name=ChannelName.PULSE1, + feature_key=FeatureKey.VOLUME, + ), + "retune": Retune(reconstruction=reconstruction_factory(), nes_frequency=RETUNED_FREQUENCY), + "removal": StemRemoval(reconstruction=reconstruction_factory(), stem_name="kick"), + } + + reconstruction_coordinator.apply_edit(edits[edit_kind]) + + reconstruction_coordinator._reconstruction_manager.mark_updated.assert_called_once_with() + + def test_a_change_that_will_never_land_redraws_the_open_document( + self, + reconstruction_coordinator: ReconstructionCoordinator, + tab: MagicMock, + ) -> None: + """The panel drew the change, so it draws the document as it stands once the change is let go.""" + reconstruction_coordinator._rewrites.on_dropped() - assert observed == [prior] - assert manager.reconstruction is remaining + tab.redraw_reconstruction.assert_called_once_with(refit_waveform=False) class TestReconstructionRestorePropagatesUnexpected: @@ -284,3 +486,1263 @@ def test_load_prompts_only_for_standalone_unsaved( else: coordinator._dialogs.show_save_confirmation.assert_not_called() coordinator._reconstructions_tab.load_reconstruction.assert_called_once_with(path) + + +@pytest.fixture +def project_manager() -> ProjectManager: + return ProjectManager() + + +@pytest.fixture +def project_controller(project_manager: ProjectManager) -> ProjectController: + return ProjectController(project_manager) + + +@pytest.fixture +def history(project_controller: ProjectController) -> HistoryManager: + """A strict history, so an edit that reaches the project outside a transaction is reported.""" + history = HistoryManager(project_controller, budget=HISTORY_BUDGET, strict=True) + project_controller.on_mutation = history.handle_mutation + project_controller.on_saved = history.mark_saved + return history + + +@pytest.fixture +def reconstruction_manager(scheduling: SchedulingBehavior) -> ReconstructionManager: + return ReconstructionManager(scheduling=scheduling) + + +@pytest.fixture +def tab() -> MagicMock: + return MagicMock(spec=ReconstructionTabCoordinator) + + +@pytest.fixture +def rewrites(reconstruction_manager: ReconstructionManager) -> ReconstructionRewrites: + """The steps of the open document, rebuilt by the real regeneration on a synchronous worker.""" + return ReconstructionRewrites(reconstruction_manager, RegenerationService()) + + +@pytest.fixture +def following_coordinator( + rewrites: ReconstructionRewrites, + reconstruction_manager: ReconstructionManager, + project_manager: ProjectManager, + project_controller: ProjectController, + history: HistoryManager, + tab: MagicMock, + held_queue: HeldQueue, +) -> ReconstructionCoordinator: + """The coordinator on a real project, history and document, wired the way the application wires it. + + The application writes an edit of a project sample back into the project as one history entry, + follows every project state change, and fans a replaced project out to the sequencer, which + reseeds the history, before the coordinator follows it. The fixture repeats that wiring, and a + new project stands open. + """ + + def write_back(edit: ReconstructionEdit) -> None: + voice_id = reconstruction_manager.voice_id + if voice_id is None: + return + + with history.transaction( + edit.history_action, + coalesce=edit.coalesce_key(voice_id), + ): + project_controller.replace_sample_reconstruction(voice_id, edit.reconstruction) + + coordinator = ReconstructionCoordinator( + reconstruction_manager, + MagicMock(), + rewrites, + MagicMock(), + project_controller, + history, + dialogs=MagicMock(), + language_manager=FakeLanguageManager({}), + on_tab_switch=MagicMock(), + on_session_state_changed=MagicMock(), + on_reconstruction_updated=write_back, + ) + coordinator.set_reconstructions_tab(tab) + + def realign() -> None: + history.reset() + coordinator.follow_replaced_project() + + project_manager.session.on_state_changed = coordinator.follow_project + project_controller.on_project_replaced = realign + project_controller.new() + return coordinator + + +@pytest.fixture +def open_sample( + following_coordinator: ReconstructionCoordinator, + project_controller: ProjectController, + history: HistoryManager, + tab: MagicMock, + held_queue: HeldQueue, + reconstruction_factory: ReconstructionFactory, +) -> Sample: + """A sample added to the project and opened on the tab, with the calls opening it cleared.""" + with history.transaction(HistoryAction.ADD_SAMPLE): + sample = project_controller.add_sample(reconstruction_factory(), "lead") + following_coordinator.open_project_voice(sample.id) + held_queue.drain() + tab.reset_mock() + return sample + + +@pytest.fixture +def standalone_path( + reconstruction_manager: ReconstructionManager, + reconstruction_factory: ReconstructionFactory, + tmp_path: Path, +) -> Path: + """A reconstruction file opened on the tab, standing apart from the project.""" + path = tmp_path / "lead.stn" + reconstruction_factory().save(path) + reconstruction_manager.load_reconstruction(path) + return path + + +def _retimed(reconstruction: Reconstruction) -> Reconstruction: + """The same reconstruction timed at another NES frequency, which spans another length.""" + return reconstruction.model_copy( + update={"config": reconstruction.config.with_library(nes_frequency=reconstruction.config.nes_frequency // 2)} + ) + + +class TestTheTabFollowsTheVoiceItShows: + """The tab knows the voice it shows by its id, so a restore reaches it and a replaced project lets it go.""" + + def test_an_undo_keeping_the_sample_shows_the_reconstruction_it_restores( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + history: HistoryManager, + open_sample: Sample, + tab: MagicMock, + reconstruction_factory: ReconstructionFactory, + ) -> None: + original = open_sample.reconstruction + following_coordinator.apply_edit( + ChannelEdit( + reconstruction=reconstruction_factory(), + channel_name=ChannelName.PULSE1, + feature_key=FeatureKey.VOLUME, + ) + ) + tab.reset_mock() + + history.undo() + + assert reconstruction_manager.reconstruction is original + assert reconstruction_manager.voice_id == open_sample.id + tab.redraw_reconstruction.assert_called_once_with(refit_waveform=False) + + def test_an_undo_across_a_rate_change_refits_the_waveform( + self, + following_coordinator: ReconstructionCoordinator, + project_controller: ProjectController, + history: HistoryManager, + open_sample: Sample, + tab: MagicMock, + ) -> None: + _retime(following_coordinator, open_sample.reconstruction.config.nes_frequency // 2) + tab.reset_mock() + + history.undo() + + tab.redraw_reconstruction.assert_called_once_with(refit_waveform=True) + + def test_an_undo_leaving_the_reconstruction_as_it_was_redraws_nothing( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + project_controller: ProjectController, + history: HistoryManager, + open_sample: Sample, + tab: MagicMock, + ) -> None: + """A restore shares every reconstruction it kept, so the document it already shows stands.""" + with history.transaction(HistoryAction.SET_TEMPO): + project_controller.set_tempo(150) + tab.reset_mock() + + history.undo() + + assert reconstruction_manager.reconstruction is open_sample.reconstruction + tab.redraw_reconstruction.assert_not_called() + tab.update_reconstruction.assert_not_called() + + def test_an_undo_taking_the_sample_out_closes_it( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + history: HistoryManager, + open_sample: Sample, + ) -> None: + history.undo() + + assert reconstruction_manager.current_reconstruction is None + assert reconstruction_manager.voice_id is None + + def test_a_redo_bringing_the_sample_back_leaves_the_tab_empty( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + project_controller: ProjectController, + history: HistoryManager, + open_sample: Sample, + ) -> None: + """A voice that comes back is the reader's to open again.""" + history.undo() + + history.redo() + + assert project_controller.project.voice(open_sample.id) is not None + assert reconstruction_manager.current_reconstruction is None + + def test_a_redo_taking_the_sample_out_closes_it( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + project_controller: ProjectController, + history: HistoryManager, + open_sample: Sample, + ) -> None: + with history.transaction(HistoryAction.REMOVE_VOICE): + project_controller.remove_voice(open_sample.id) + history.undo() + following_coordinator.open_project_voice(open_sample.id) + + history.redo() + + assert reconstruction_manager.current_reconstruction is None + + def test_removing_the_sample_closes_it( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + project_controller: ProjectController, + history: HistoryManager, + open_sample: Sample, + ) -> None: + with history.transaction(HistoryAction.REMOVE_VOICE): + project_controller.remove_voice(open_sample.id) + + assert reconstruction_manager.current_reconstruction is None + + def test_an_edit_writing_the_project_first_leaves_the_document_to_the_edit( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + open_sample: Sample, + tab: MagicMock, + reconstruction_factory: ReconstructionFactory, + ) -> None: + """The write-back stamps the project while the document still holds what the edit started from.""" + edited = reconstruction_factory() + + following_coordinator.apply_edit( + ChannelEdit( + reconstruction=edited, + channel_name=ChannelName.PULSE1, + feature_key=FeatureKey.VOLUME, + ) + ) + + assert reconstruction_manager.reconstruction is edited + tab.redraw_reconstruction.assert_not_called() + tab.update_reconstruction.assert_called_once_with() + + def test_a_new_project_lets_the_sample_go( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + project_controller: ProjectController, + open_sample: Sample, + ) -> None: + project_controller.new() + + assert reconstruction_manager.current_reconstruction is None + + def test_closing_the_project_lets_the_sample_go( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + project_controller: ProjectController, + open_sample: Sample, + ) -> None: + project_controller.close() + + assert reconstruction_manager.current_reconstruction is None + + def test_reopening_the_saved_project_lets_the_sample_go( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + project_controller: ProjectController, + open_sample: Sample, + tmp_path: Path, + ) -> None: + """The file keeps each voice's id, so the reopened project names the voice the tab showed.""" + path = tmp_path / f"song{EXT_FILE_PROJECT}" + project_controller.save(path) + + project_controller.load(path) + + assert isinstance(project_controller.project.voice(open_sample.id), Sample) + assert reconstruction_manager.current_reconstruction is None + + def test_a_standalone_document_outlasts_a_new_and_a_closed_project( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + project_controller: ProjectController, + standalone_path: Path, + ) -> None: + project_controller.new() + project_controller.close() + + assert reconstruction_manager.filepath == standalone_path + + def test_a_restore_asks_the_instrument_to_follow_as_restored( + self, + following_coordinator: ReconstructionCoordinator, + project_controller: ProjectController, + history: HistoryManager, + tab: MagicMock, + ) -> None: + with history.transaction(HistoryAction.SET_TEMPO): + project_controller.set_tempo(150) + tab.reset_mock() + + history.undo() + + tab.follow_instrument.assert_called_with(restored=True) + + def test_an_edit_asks_the_instrument_to_follow_as_it_stands( + self, + following_coordinator: ReconstructionCoordinator, + project_controller: ProjectController, + history: HistoryManager, + tab: MagicMock, + ) -> None: + tab.reset_mock() + + with history.transaction(HistoryAction.SET_TEMPO): + project_controller.set_tempo(150) + + tab.follow_instrument.assert_called_once_with(restored=False) + + def test_a_replaced_project_closes_the_instrument( + self, + following_coordinator: ReconstructionCoordinator, + project_controller: ProjectController, + tab: MagicMock, + ) -> None: + tab.reset_mock() + + project_controller.close() + + tab.close_instrument.assert_called_once_with() + + +class TestOpeningAProjectVoice(BaseTestSuite): + """Edit on a voice puts it in front of the tab, asking first where that would lose unsaved work.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + kind: VoiceKind + + test_cases = ( + TestCase(label="sample", kind=VoiceKind.SAMPLE), + TestCase(label="instrument", kind=VoiceKind.INSTRUMENT), + ) + + @staticmethod + def _add_voice( + kind: VoiceKind, + project_controller: ProjectController, + history: HistoryManager, + reconstruction_factory: ReconstructionFactory, + ) -> str: + match kind: + case VoiceKind.SAMPLE: + with history.transaction(HistoryAction.ADD_SAMPLE): + return project_controller.add_sample(reconstruction_factory(), "lead").id + case VoiceKind.INSTRUMENT: + with history.transaction(HistoryAction.ADD_INSTRUMENT): + return project_controller.add_instrument(new_instrument("lead")).id + + def test_a_sample_opens_as_the_document_it_is( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + project_controller: ProjectController, + history: HistoryManager, + tab: MagicMock, + reconstruction_factory: ReconstructionFactory, + ) -> None: + voice_id = self._add_voice(VoiceKind.SAMPLE, project_controller, history, reconstruction_factory) + sample = project_controller.project.voice(voice_id) + assert isinstance(sample, Sample) + + following_coordinator.open_project_voice(voice_id) + + assert reconstruction_manager.voice_id == voice_id + assert reconstruction_manager.reconstruction is sample.reconstruction + tab.release_instrument.assert_called_once_with() + + def test_an_instrument_opens_in_the_editor_on_its_tab( + self, + following_coordinator: ReconstructionCoordinator, + project_controller: ProjectController, + history: HistoryManager, + tab: MagicMock, + reconstruction_factory: ReconstructionFactory, + ) -> None: + voice_id = self._add_voice(VoiceKind.INSTRUMENT, project_controller, history, reconstruction_factory) + + following_coordinator.open_project_voice(voice_id) + + tab.edit_instrument.assert_called_once_with(voice_id) + following_coordinator._on_tab_switch.assert_called_once_with(Tab.RECONSTRUCTIONS) + + def test_an_unknown_voice_opens_nothing( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + tab: MagicMock, + ) -> None: + following_coordinator.open_project_voice("gone") + + assert reconstruction_manager.current_reconstruction is None + tab.edit_instrument.assert_not_called() + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_an_unsaved_standalone_document_is_offered_a_save_first( + self, + test_case: TestCase, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + project_controller: ProjectController, + history: HistoryManager, + tab: MagicMock, + standalone_path: Path, + reconstruction_factory: ReconstructionFactory, + ) -> None: + voice_id = self._add_voice(test_case.kind, project_controller, history, reconstruction_factory) + reconstruction_manager.mark_updated() + + following_coordinator.open_project_voice(voice_id) + + following_coordinator._dialogs.show_save_confirmation.assert_called_once() + assert reconstruction_manager.filepath == standalone_path + tab.edit_instrument.assert_not_called() + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_the_answer_opens_the_voice( + self, + test_case: TestCase, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + project_controller: ProjectController, + history: HistoryManager, + tab: MagicMock, + standalone_path: Path, + reconstruction_factory: ReconstructionFactory, + ) -> None: + voice_id = self._add_voice(test_case.kind, project_controller, history, reconstruction_factory) + reconstruction_manager.mark_updated() + following_coordinator.open_project_voice(voice_id) + + following_coordinator._dialogs.show_save_confirmation.call_args.kwargs["on_confirm"]() + + match test_case.kind: + case VoiceKind.SAMPLE: + assert reconstruction_manager.voice_id == voice_id + case VoiceKind.INSTRUMENT: + tab.edit_instrument.assert_called_once_with(voice_id) + + def test_an_edited_project_sample_opens_another_voice_at_once( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + project_controller: ProjectController, + history: HistoryManager, + open_sample: Sample, + reconstruction_factory: ReconstructionFactory, + ) -> None: + """A project sample's edits belong to the project, so putting it away loses nothing.""" + voice_id = self._add_voice(VoiceKind.SAMPLE, project_controller, history, reconstruction_factory) + reconstruction_manager.mark_updated() + + following_coordinator.open_project_voice(voice_id) + + following_coordinator._dialogs.show_save_confirmation.assert_not_called() + assert reconstruction_manager.voice_id == voice_id + + +class TestAnOutsideRewriteOfTheOpenSample: + """A sample replaced or retuned from the sequencer shows on the tab only where the tab has it open.""" + + def test_a_replaced_sample_shows_its_new_reconstruction( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + open_sample: Sample, + tab: MagicMock, + reconstruction_factory: ReconstructionFactory, + ) -> None: + incoming = reconstruction_factory() + + following_coordinator.replace_sample(open_sample.id, incoming) + + assert reconstruction_manager.reconstruction is incoming + tab.redraw_reconstruction.assert_called_once_with(refit_waveform=False) + + def test_a_replacement_at_another_rate_refits_the_waveform( + self, + following_coordinator: ReconstructionCoordinator, + open_sample: Sample, + tab: MagicMock, + ) -> None: + following_coordinator.replace_sample(open_sample.id, _retimed(open_sample.reconstruction)) + + tab.redraw_reconstruction.assert_called_once_with(refit_waveform=True) + + def test_a_replaced_sample_the_tab_holds_no_longer_leaves_the_document_alone( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + open_sample: Sample, + tab: MagicMock, + reconstruction_factory: ReconstructionFactory, + ) -> None: + following_coordinator.replace_sample("another-id", reconstruction_factory()) + + assert reconstruction_manager.reconstruction is open_sample.reconstruction + tab.redraw_reconstruction.assert_not_called() + + def test_a_replacement_with_nothing_open_opens_nothing( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + tab: MagicMock, + reconstruction_factory: ReconstructionFactory, + ) -> None: + following_coordinator.replace_sample(OPEN_VOICE_ID, reconstruction_factory()) + + assert reconstruction_manager.current_reconstruction is None + tab.redraw_reconstruction.assert_not_called() + + def test_a_retuned_sample_shows_its_new_length( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + open_sample: Sample, + tab: MagicMock, + ) -> None: + """A retune carries every envelope over, so the panel keeps what it draws.""" + half = open_sample.reconstruction.config.nes_frequency // 2 + + _retime(following_coordinator, half) + + reconstruction = reconstruction_manager.reconstruction + assert reconstruction is not None + assert reconstruction.config.nes_frequency == half + tab.update_reconstruction.assert_called_once_with(refit_waveform=True) + tab.redraw_reconstruction.assert_not_called() + + def test_a_retune_with_nothing_open_opens_nothing( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + tab: MagicMock, + ) -> None: + _retime(following_coordinator, RETUNED_FREQUENCY) + + assert reconstruction_manager.current_reconstruction is None + tab.update_reconstruction.assert_not_called() + + +class TestTheSaveAPromptWaitsOn: + """A save prompt goes on, asks again or stands aside according to what the save came to.""" + + def test_a_document_with_a_file_is_written( + self, + reconstruction_coordinator: ReconstructionCoordinator, + ) -> None: + reconstruction_coordinator._reconstruction_manager.save_reconstruction.return_value = True + + assert reconstruction_coordinator.save() is SaveOutcome.WRITTEN + + def test_a_project_sample_has_nothing_to_write_to( + self, + reconstruction_coordinator: ReconstructionCoordinator, + ) -> None: + manager = reconstruction_coordinator._reconstruction_manager + manager.is_file_backed = False + manager.is_project_sample = True + manager.save_reconstruction.return_value = False + + assert reconstruction_coordinator.save() is SaveOutcome.CALLED_OFF + + def test_a_document_whose_file_was_taken_away_asks_where_to_go( + self, + reconstruction_coordinator: ReconstructionCoordinator, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + ) -> None: + """The save a prompt waits on writes it where the reader picks, and goes on without a word.""" + chosen = tmp_path / "lead.stn" + monkeypatch.setattr(reconstruction_module, "save_file_dialog", MagicMock(return_value=chosen)) + manager = self._fileless(reconstruction_coordinator) + + assert reconstruction_coordinator.save() is SaveOutcome.WRITTEN + manager.save_reconstruction_as.assert_called_once_with(chosen) + reconstruction_coordinator._dialogs.show_info.assert_not_called() + + def test_a_file_dialog_closed_without_a_name_calls_the_save_off( + self, + reconstruction_coordinator: ReconstructionCoordinator, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setattr(reconstruction_module, "save_file_dialog", MagicMock(return_value=None)) + manager = self._fileless(reconstruction_coordinator) + + assert reconstruction_coordinator.save() is SaveOutcome.CALLED_OFF + manager.save_reconstruction_as.assert_not_called() + + def test_save_as_asked_for_by_itself_says_it_landed( + self, + reconstruction_coordinator: ReconstructionCoordinator, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + ) -> None: + monkeypatch.setattr(reconstruction_module, "save_file_dialog", MagicMock(return_value=tmp_path / "lead.stn")) + reconstruction_coordinator.set_reconstructions_tab(MagicMock()) + + assert reconstruction_coordinator.save_as_dialog() is SaveOutcome.WRITTEN + reconstruction_coordinator._dialogs.show_info.assert_called_once() + + @staticmethod + def _fileless(coordinator: ReconstructionCoordinator) -> MagicMock: + """A standalone document with no file of its own, the way removing its file leaves it.""" + coordinator.set_reconstructions_tab(MagicMock()) + manager: MagicMock = coordinator._reconstruction_manager + manager.is_file_backed = False + manager.is_project_sample = False + manager.current_reconstruction.filepath = None + manager.current_reconstruction.name = "lead" + return manager + + def test_a_write_that_fails_shows_its_error( + self, + reconstruction_coordinator: ReconstructionCoordinator, + ) -> None: + failure = OSError("disk full") + reconstruction_coordinator._reconstruction_manager.save_reconstruction.side_effect = failure + + assert reconstruction_coordinator.save() is SaveOutcome.FAILED + assert reconstruction_coordinator._dialogs.show_error.call_args.args[0] is failure + + +NO_PROMPT: Final[str] = "none" +SAVE_PROMPT: Final[str] = "save" +REPLACED_PROMPT: Final[str] = "replaced" +REPLACED_MESSAGE_KEY: Final[str] = "global.dialog.message.load_replaced_reconstruction" +DISCARD_LABEL_KEY: Final[str] = "global.dialog.label.discard" + + +class TestLoadingAConversion(BaseTestSuite): + """Loading what a conversion wrote asks first about unsaved changes. A conversion that wrote over + the open document's own file offers to discard the changes or keep them, since a save would write + the old document over the new one.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + unsaved: bool + embedded: bool + same_file: bool + + test_cases = ( + TestCase(label="saved_another_file", unsaved=False, embedded=False, same_file=False, expected=NO_PROMPT), + TestCase(label="saved_its_own_file", unsaved=False, embedded=False, same_file=True, expected=NO_PROMPT), + TestCase(label="unsaved_another_file", unsaved=True, embedded=False, same_file=False, expected=SAVE_PROMPT), + TestCase(label="unsaved_its_own_file", unsaved=True, embedded=False, same_file=True, expected=REPLACED_PROMPT), + TestCase(label="project_sample_another_file", unsaved=True, embedded=True, same_file=False, expected=NO_PROMPT), + TestCase(label="project_sample_its_own_file", unsaved=True, embedded=True, same_file=True, expected=NO_PROMPT), + ) + + @staticmethod + def _coordinator( + test_case: TestCase, + reconstruction_factory: ReconstructionFactory, + tmp_path: Path, + ) -> ReconstructionCoordinator: + """A coordinator over the document in ``open.stn``, opened from its file or as a project sample, unsaved or saved as the case says.""" + opened = tmp_path / "open.stn" + reconstruction_factory().save(opened) + manager = ReconstructionManager(scheduling=MagicMock()) + coordinator = ReconstructionCoordinator( + manager, + MagicMock(), + MagicMock(), + MagicMock(), + MagicMock(), + MagicMock(), + dialogs=MagicMock(), + language_manager=FakeLanguageManager(), + on_tab_switch=MagicMock(), + on_session_state_changed=MagicMock(), + on_reconstruction_updated=MagicMock(), + ) + coordinator.set_reconstructions_tab(MagicMock()) + if test_case.embedded: + manager.load_reconstruction_object( + Reconstruction.load(opened), + name=opened.stem, + voice_id=OPEN_VOICE_ID, + ) + else: + manager.load_reconstruction(opened) + if test_case.unsaved: + manager.mark_updated() + + return coordinator + + @staticmethod + def _converted(test_case: TestCase, tmp_path: Path) -> Path: + """What the conversion wrote: the open file spelled another way, or a file of its own.""" + if not test_case.same_file: + return tmp_path / "converted.stn" + + (tmp_path / "folder").mkdir() + return tmp_path / "folder" / ".." / "open.stn" + + @staticmethod + def _asked(coordinator: ReconstructionCoordinator) -> str: + dialogs = coordinator._dialogs + if dialogs.show_save_confirmation.called: + return SAVE_PROMPT + + if dialogs.show_confirmation.called: + assert dialogs.show_confirmation.call_args.kwargs["tag"] == TAG_GLOBAL_DIALOG_RECONSTRUCTION_REPLACED + return REPLACED_PROMPT + + return NO_PROMPT + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_the_question_put_before_loading( + self, + test_case: TestCase, + reconstruction_factory: ReconstructionFactory, + tmp_path: Path, + ) -> None: + coordinator = self._coordinator(test_case, reconstruction_factory, tmp_path) + + coordinator.load_converted(self._converted(test_case, tmp_path)) + + assert self._asked(coordinator) == test_case.expected + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_going_on_loads_what_the_conversion_wrote( + self, + test_case: TestCase, + reconstruction_factory: ReconstructionFactory, + tmp_path: Path, + ) -> None: + coordinator = self._coordinator(test_case, reconstruction_factory, tmp_path) + converted = self._converted(test_case, tmp_path) + + coordinator.load_converted(converted) + asked = self._asked(coordinator) + if asked == SAVE_PROMPT: + coordinator._dialogs.show_save_confirmation.call_args.kwargs["on_confirm"]() + elif asked == REPLACED_PROMPT: + coordinator._dialogs.show_confirmation.call_args.kwargs["on_confirm"]() + + coordinator._tab.load_reconstruction.assert_called_once_with(converted) + + @pytest.mark.parametrize( + "test_case", + [test_case for test_case in test_cases if test_case.expected != NO_PROMPT], + ids=lambda test_case: test_case.label, + ) + def test_nothing_loads_before_the_answer( + self, + test_case: TestCase, + reconstruction_factory: ReconstructionFactory, + tmp_path: Path, + ) -> None: + coordinator = self._coordinator(test_case, reconstruction_factory, tmp_path) + + coordinator.load_converted(self._converted(test_case, tmp_path)) + + coordinator._tab.load_reconstruction.assert_not_called() + assert coordinator.is_unsaved() + + def test_a_replaced_file_offers_to_discard_the_changes( + self, + reconstruction_factory: ReconstructionFactory, + tmp_path: Path, + ) -> None: + """Saving would write the old document over the conversion, so the prompt offers no Save.""" + test_case = next(test_case for test_case in self.test_cases if test_case.expected == REPLACED_PROMPT) + coordinator = self._coordinator(test_case, reconstruction_factory, tmp_path) + + coordinator.load_converted(self._converted(test_case, tmp_path)) + + prompt = coordinator._dialogs.show_confirmation.call_args.kwargs + assert prompt["message"] == REPLACED_MESSAGE_KEY + assert prompt["ok_label"] == DISCARD_LABEL_KEY + coordinator._dialogs.show_save_confirmation.assert_not_called() + + def test_the_save_prompt_saves_the_open_document( + self, + reconstruction_factory: ReconstructionFactory, + tmp_path: Path, + ) -> None: + test_case = next(test_case for test_case in self.test_cases if test_case.expected == SAVE_PROMPT) + coordinator = self._coordinator(test_case, reconstruction_factory, tmp_path) + coordinator.load_converted(self._converted(test_case, tmp_path)) + + outcome = coordinator._dialogs.show_save_confirmation.call_args.kwargs["on_save"]() + + assert outcome is SaveOutcome.WRITTEN + assert not coordinator.is_unsaved() + + +class TestTheExitAsksAboutTheReconstruction(BaseTestSuite): + """Exiting with unsaved changes in a reconstruction of its own asks to save them first. A project + sample's changes belong to the project, which the exit asks about already.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + unsaved: bool + embedded: bool + + test_cases = ( + TestCase(label="standalone_unsaved_asks", unsaved=True, embedded=False, expected=True), + TestCase(label="standalone_saved_goes_on", unsaved=False, embedded=False, expected=False), + TestCase(label="project_sample_unsaved_goes_on", unsaved=True, embedded=True, expected=False), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_the_exit_asks_only_about_a_standalone_document(self, test_case: TestCase) -> None: + coordinator = _gating_coordinator(unsaved=test_case.unsaved, embedded=test_case.embedded) + proceed = MagicMock() + + coordinator.guard_exit(proceed) + + assert coordinator._dialogs.show_save_confirmation.called is test_case.expected + assert proceed.called is not test_case.expected + + def test_the_answer_lets_the_exit_go_on(self) -> None: + coordinator = _gating_coordinator(unsaved=True, embedded=False) + proceed = MagicMock() + + coordinator.guard_exit(proceed) + + prompt = coordinator._dialogs.show_save_confirmation.call_args.kwargs + assert prompt["on_save"] == coordinator.save + assert prompt["on_confirm"] is proceed + + +@pytest.fixture +def turns_sample( + following_coordinator: ReconstructionCoordinator, + project_controller: ProjectController, + history: HistoryManager, + tab: MagicMock, + held_queue: HeldQueue, + taking_turns: Reconstruction, +) -> Sample: + """The two-recording document added to the project and opened on the tab.""" + with history.transaction(HistoryAction.ADD_SAMPLE): + sample = project_controller.add_sample(taking_turns, "turns") + following_coordinator.open_project_voice(sample.id) + held_queue.drain() + tab.reset_mock() + return sample + + +@pytest.fixture +def turns_path( + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + tab: MagicMock, + held_queue: HeldQueue, + taking_turns_file: Path, +) -> Path: + """The two-recording document opened from its file, with both recordings loaded beside it.""" + reconstruction_manager.load_reconstruction(taking_turns_file) + held_queue.drain() + tab.reset_mock() + return taking_turns_file + + +class TestTheDocumentChangesOneStepAtATime: + """The tab changes its open document one step at a time, in the order the reader made the changes. + + Each step is built from the document the previous step left, a result whose document has since + been put away is dropped, and the gestures that read or put away the whole document wait for the + edits before them. + """ + + def test_a_removal_after_an_edit_keeps_the_edit_and_takes_the_recording_out( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + held_queue: HeldQueue, + turns_sample: Sample, + ) -> None: + _move(following_coordinator, SHARED_CHANNEL, FeatureKey.VOLUME, EDITED_VOLUME) + _remove(following_coordinator, STEM_B_ID) + held_queue.drain() + + reconstruction = reconstruction_manager.reconstruction + assert reconstruction is not None + assert STEM_B_ID not in reconstruction.stems_data.config.entries_by_id + assert _volumes(reconstruction, SHARED_CHANNEL)[SHARED_OWNERS.index(STEM_A_ID)] == EDITED_VOLUME[0] + + def test_a_removal_after_an_edit_keeps_the_audio_of_the_recording_that_stays( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + held_queue: HeldQueue, + turns_path: Path, + ) -> None: + _move(following_coordinator, SHARED_CHANNEL, FeatureKey.VOLUME, EDITED_VOLUME) + _remove(following_coordinator, STEM_B_ID) + held_queue.drain() + + data = reconstruction_manager.current_reconstruction + assert data is not None + assert len(data.stem_audios) == 1 + + def test_loading_another_document_mid_flight_leaves_it_as_it_was( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + held_queue: HeldQueue, + turns_path: Path, + reconstruction_factory: ReconstructionFactory, + tmp_path: Path, + ) -> None: + other = tmp_path / "other.stn" + reconstruction_factory().save(other) + _move(following_coordinator, SHARED_CHANNEL, FeatureKey.VOLUME, EDITED_VOLUME) + reconstruction_manager.load_reconstruction(other) + loaded = reconstruction_manager.reconstruction + held_queue.drain() + + assert reconstruction_manager.reconstruction is loaded + assert not following_coordinator.is_unsaved() + + def test_opening_another_voice_mid_flight_leaves_it_as_it_was( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + project_controller: ProjectController, + history: HistoryManager, + held_queue: HeldQueue, + turns_sample: Sample, + reconstruction_factory: ReconstructionFactory, + ) -> None: + with history.transaction(HistoryAction.ADD_SAMPLE): + other = project_controller.add_sample(reconstruction_factory(), "other") + original = other.reconstruction + entries = len(history.entries) + _move(following_coordinator, SHARED_CHANNEL, FeatureKey.VOLUME, EDITED_VOLUME) + following_coordinator.open_project_voice(other.id) + held_queue.drain() + + sample = project_controller.project.voice(other.id) + assert isinstance(sample, Sample) + assert sample.reconstruction is original + assert reconstruction_manager.reconstruction is original + assert len(history.entries) == entries + + def test_closing_mid_flight_leaves_nothing_unsaved( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + held_queue: HeldQueue, + turns_path: Path, + ) -> None: + _move(following_coordinator, SHARED_CHANNEL, FeatureKey.VOLUME, EDITED_VOLUME) + following_coordinator.close_with_confirmation() + held_queue.drain() + + assert reconstruction_manager.current_reconstruction is None + assert not following_coordinator.is_unsaved() + + def test_two_dimensions_of_one_channel_both_land( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + held_queue: HeldQueue, + turns_sample: Sample, + ) -> None: + _move(following_coordinator, SHARED_CHANNEL, FeatureKey.VOLUME, EDITED_VOLUME) + _move(following_coordinator, SHARED_CHANNEL, FeatureKey.ARPEGGIO, ARPEGGIO) + held_queue.drain() + + reconstruction = reconstruction_manager.reconstruction + assert reconstruction is not None + assert _sounding_volumes(reconstruction, SHARED_CHANNEL) == list(EDITED_VOLUME) + assert _sounding_pitches(reconstruction, SHARED_CHANNEL) == [TAKING_TURNS_PITCH + offset for offset in ARPEGGIO] + + def test_two_channels_both_land( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + held_queue: HeldQueue, + turns_sample: Sample, + ) -> None: + _move(following_coordinator, SHARED_CHANNEL, FeatureKey.VOLUME, EDITED_VOLUME) + _move(following_coordinator, SOLE_CHANNEL, FeatureKey.VOLUME, SOLE_VOLUME) + held_queue.drain() + + reconstruction = reconstruction_manager.reconstruction + assert reconstruction is not None + assert _sounding_volumes(reconstruction, SHARED_CHANNEL) == list(EDITED_VOLUME) + assert _sounding_volumes(reconstruction, SOLE_CHANNEL) == list(SOLE_VOLUME) + + def test_a_rate_change_after_an_edit_keeps_the_edit_at_the_new_rate( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + held_queue: HeldQueue, + turns_path: Path, + ) -> None: + _move(following_coordinator, SHARED_CHANNEL, FeatureKey.VOLUME, EDITED_VOLUME) + _retime(following_coordinator, RETUNED_FREQUENCY) + held_queue.drain() + + reconstruction = reconstruction_manager.reconstruction + assert reconstruction is not None + assert reconstruction.config.nes_frequency == RETUNED_FREQUENCY + assert _sounding_volumes(reconstruction, SHARED_CHANNEL) == list(EDITED_VOLUME) + + def test_an_undo_right_after_an_edit_undoes_the_edit( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + project_controller: ProjectController, + history: HistoryManager, + held_queue: HeldQueue, + turns_sample: Sample, + ) -> None: + original = turns_sample.reconstruction + _move(following_coordinator, SHARED_CHANNEL, FeatureKey.VOLUME, EDITED_VOLUME) + _undo(following_coordinator, history) + held_queue.drain() + + assert reconstruction_manager.voice_id == turns_sample.id + assert reconstruction_manager.reconstruction is original + assert project_controller.project.voice(turns_sample.id) is not None + history.redo() + held_queue.drain() + reconstruction = reconstruction_manager.reconstruction + assert reconstruction is not None + assert _sounding_volumes(reconstruction, SHARED_CHANNEL) == list(EDITED_VOLUME) + + def test_a_save_right_after_an_edit_writes_the_edit( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + held_queue: HeldQueue, + turns_path: Path, + ) -> None: + _move(following_coordinator, SHARED_CHANNEL, FeatureKey.VOLUME, EDITED_VOLUME) + _save(following_coordinator) + held_queue.drain() + + assert _sounding_volumes(Reconstruction.load(turns_path), SHARED_CHANNEL) == list(EDITED_VOLUME) + + def test_a_save_as_right_after_an_edit_writes_the_edit( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + held_queue: HeldQueue, + turns_path: Path, + tmp_path: Path, + ) -> None: + copy = tmp_path / "copy.stn" + _move(following_coordinator, SHARED_CHANNEL, FeatureKey.VOLUME, EDITED_VOLUME) + following_coordinator.after_edits(lambda: reconstruction_manager.save_reconstruction_as(copy)) + held_queue.drain() + + assert _sounding_volumes(Reconstruction.load(copy), SHARED_CHANNEL) == list(EDITED_VOLUME) + + def test_a_result_meeting_a_copy_put_in_its_place_is_let_go( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + held_queue: HeldQueue, + tab: MagicMock, + turns_path: Path, + tmp_path: Path, + ) -> None: + """A Save As adopting its copy while a rebuild runs leaves the copy as written, and the panel redrawn.""" + _move(following_coordinator, SHARED_CHANNEL, FeatureKey.VOLUME, EDITED_VOLUME) + reconstruction_manager.save_reconstruction_as(tmp_path / "copy.stn") + copy = reconstruction_manager.reconstruction + held_queue.drain() + + assert reconstruction_manager.reconstruction is copy + tab.redraw_reconstruction.assert_called_once_with(refit_waveform=False) + + def test_an_edit_drawn_before_a_waiting_removal_is_refused( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + held_queue: HeldQueue, + turns_sample: Sample, + ) -> None: + """The panel drew the edit on frames the removal is about to release, so it would bring them back.""" + _move(following_coordinator, SHARED_CHANNEL, FeatureKey.VOLUME, EDITED_VOLUME) + _remove(following_coordinator, STEM_B_ID) + _move(following_coordinator, SHARED_CHANNEL, FeatureKey.VOLUME, LATER_VOLUME) + held_queue.drain() + + reconstruction = reconstruction_manager.reconstruction + assert reconstruction is not None + assert STEM_B_ID not in reconstruction.stems_data.config.entries_by_id + assert _volumes(reconstruction, SHARED_CHANNEL) == [EDITED_VOLUME[0], SILENT_VOLUME] + + def test_an_edit_refused_behind_a_waiting_gesture_is_redrawn_away( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + held_queue: HeldQueue, + tab: MagicMock, + turns_sample: Sample, + ) -> None: + """Once the line empties, the panel draws the document, which the refused edit never reached.""" + gesture = MagicMock() + _move(following_coordinator, SHARED_CHANNEL, FeatureKey.VOLUME, EDITED_VOLUME) + following_coordinator.after_edits(gesture) + _move(following_coordinator, SHARED_CHANNEL, FeatureKey.VOLUME, LATER_VOLUME) + held_queue.drain() + + reconstruction = reconstruction_manager.reconstruction + assert reconstruction is not None + assert _sounding_volumes(reconstruction, SHARED_CHANNEL) == list(EDITED_VOLUME) + gesture.assert_called_once_with() + tab.redraw_reconstruction.assert_called_once_with(refit_waveform=False) + + def test_a_gesture_waits_for_the_edits_before_it( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + held_queue: HeldQueue, + turns_sample: Sample, + ) -> None: + read: List[Optional[Reconstruction]] = [] + _move(following_coordinator, SHARED_CHANNEL, FeatureKey.VOLUME, EDITED_VOLUME) + + following_coordinator.after_edits(lambda: read.append(reconstruction_manager.reconstruction)) + assert read == [] + held_queue.drain() + + assert len(read) == 1 + assert read[0] is not None + assert _sounding_volumes(read[0], SHARED_CHANNEL) == list(EDITED_VOLUME) + + def test_a_gesture_with_nothing_on_its_way_runs_at_once( + self, + following_coordinator: ReconstructionCoordinator, + turns_sample: Sample, + ) -> None: + gesture = MagicMock() + + following_coordinator.after_edits(gesture) + + gesture.assert_called_once_with() + + def test_a_failed_rebuild_is_shown_and_the_next_step_still_runs( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + held_queue: HeldQueue, + turns_sample: Sample, + ) -> None: + failure = RuntimeError("synthesis failed") + failing = MagicMock() + failing.from_features.side_effect = failure + exporters = {**CHANNEL_TO_EXPORTER_MAP, SHARED_CHANNEL: failing} + with patch("sampletones_application.services.regeneration.service.CHANNEL_TO_EXPORTER_MAP", exporters): + _move(following_coordinator, SHARED_CHANNEL, FeatureKey.VOLUME, EDITED_VOLUME) + _move(following_coordinator, SOLE_CHANNEL, FeatureKey.VOLUME, SOLE_VOLUME) + held_queue.drain() + + reconstruction = reconstruction_manager.reconstruction + assert reconstruction is not None + assert following_coordinator._dialogs.show_error.call_args.args[0] is failure + assert _sounding_volumes(reconstruction, SHARED_CHANNEL) == [TAKING_TURNS_VOLUME] * len(SHARED_OWNERS) + assert _sounding_volumes(reconstruction, SOLE_CHANNEL) == list(SOLE_VOLUME) + + def test_the_waveform_stays_faded_until_every_step_has_landed( + self, + following_coordinator: ReconstructionCoordinator, + held_queue: HeldQueue, + tab: MagicMock, + turns_sample: Sample, + ) -> None: + _move(following_coordinator, SHARED_CHANNEL, FeatureKey.VOLUME, EDITED_VOLUME) + _move(following_coordinator, SOLE_CHANNEL, FeatureKey.VOLUME, SOLE_VOLUME) + assert tab.set_reconstruction_dimmed.call_args_list == [call(True)] + + held_queue.drain() + + assert tab.set_reconstruction_dimmed.call_args_list == [call(True), call(False)] + + def test_a_drag_collapses_into_the_place_it_ended( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + project_controller: ProjectController, + history: HistoryManager, + held_queue: HeldQueue, + turns_sample: Sample, + ) -> None: + """A rebuild runs while the drag moves on, so the positions it passes through merge behind it.""" + entries = len(history.entries) + _move(following_coordinator, SHARED_CHANNEL, FeatureKey.VOLUME, LATER_VOLUME) + _move(following_coordinator, SHARED_CHANNEL, FeatureKey.VOLUME, (4, 4)) + _move(following_coordinator, SHARED_CHANNEL, FeatureKey.VOLUME, EDITED_VOLUME) + held_queue.drain() + + reconstruction = reconstruction_manager.reconstruction + assert reconstruction is not None + assert _sounding_volumes(reconstruction, SHARED_CHANNEL) == list(EDITED_VOLUME) + assert len(history.entries) == entries + 1 diff --git a/tests/unit/sampletones_application/coordinators/test_render.py b/tests/unit/sampletones_application/coordinators/test_render.py index e973f4e6d..0eff5756e 100644 --- a/tests/unit/sampletones_application/coordinators/test_render.py +++ b/tests/unit/sampletones_application/coordinators/test_render.py @@ -21,7 +21,6 @@ ) from sampletones_core.audio.writers import AudioFormat from sampletones_core.configs import Config -from sampletones_shared.types.callback import VoidCallback from tests.suite.language import FakeLanguageManager from tests.suite.render import FakeRenderService @@ -90,11 +89,7 @@ def filters(self) -> List[FileFilter]: class RenderFixture: - """The coordinator over a real render logic, a recording service, and a recorded screen. - - The frame the report waits for is taken as passing when a test asks for it, so the hand-off - from the window to the dialog that reports an outcome is walked one step at a time. - """ + """The coordinator over a real render logic, a recording service, and a recorded screen.""" def __init__( self, @@ -121,14 +116,8 @@ def __init__( self.dialogs = _DialogsRecorder() self.save_dialog = _SaveDialogRecorder() self.activity = 0 - self.pending: List[VoidCallback] = [] monkeypatch.setattr(render_module, "save_file_dialog", self.save_dialog) - monkeypatch.setattr( - render_module.FrameCallbackManager, - "set_frame_callback", - lambda callback, frame_count=1: self.pending.append(callback), - ) self.coordinator = SongRenderCoordinator( self.logic, @@ -159,13 +148,6 @@ def stop(self) -> None: def close(self) -> None: self.window.on_close() - def advance_frame(self) -> None: - """Runs what was waiting for the frame the window left the screen in.""" - pending = self.pending - self.pending = [] - for callback in pending: - callback() - @pytest.fixture def render(monkeypatch: pytest.MonkeyPatch) -> RenderFixture: @@ -292,7 +274,6 @@ def test_a_finished_render_reports_the_file_it_wrote(self, render: RenderFixture render.start() render.service.emit(ServiceSuccess(value=CHOSEN)) - render.advance_frame() assert render.dialogs.paths == [ { @@ -302,22 +283,12 @@ def test_a_finished_render_reports_the_file_it_wrote(self, render: RenderFixture } ] - def test_the_report_waits_for_the_screen_the_window_left(self, render: RenderFixture) -> None: - render.open() - render.start() - - render.service.emit(ServiceSuccess(value=CHOSEN)) - - assert render.window.hides == 1 - assert not render.dialogs.paths - def test_a_failed_render_reports_what_went_wrong(self, render: RenderFixture) -> None: render.open() render.start() failure = OSError("no room on the device") render.service.emit(ServiceError(exception=failure)) - render.advance_frame() assert render.dialogs.errors == [ { @@ -332,7 +303,6 @@ def test_a_stopped_render_closes_without_a_report(self, render: RenderFixture) - render.stop() render.service.emit(ServiceCanceled()) - render.advance_frame() assert render.window.hides == 1 assert not render.dialogs.paths diff --git a/tests/unit/sampletones_application/logic/reconstruction/rewrites/__init__.py b/tests/unit/sampletones_application/logic/reconstruction/rewrites/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_application/logic/reconstruction/rewrites/test_queue.py b/tests/unit/sampletones_application/logic/reconstruction/rewrites/test_queue.py new file mode 100644 index 000000000..f465420a4 --- /dev/null +++ b/tests/unit/sampletones_application/logic/reconstruction/rewrites/test_queue.py @@ -0,0 +1,540 @@ +from dataclasses import dataclass, field +from typing import Final, List, Optional, Tuple +from unittest.mock import MagicMock + +import pytest + +from sampletones_application.logic.reconstruction.edit import ( + ChannelEdit, + ReconstructionEdit, + Retune, + StemRemoval, +) +from sampletones_application.logic.reconstruction.manager import ReconstructionManager +from sampletones_application.logic.reconstruction.rewrites.queue import ReconstructionRewrites +from sampletones_application.logic.reconstruction.rewrites.steps import ( + AfterEdits, + ChannelChange, + RateChange, + StemRemovalRequest, +) +from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.features.envelope import Envelope +from sampletones_core.reconstructions import Reconstruction +from tests.conftest import ReconstructionFactory +from tests.suite.regeneration import HeldRegeneration +from tests.suite.stems import ( + SHARED_CHANNEL, + SOLE_CHANNEL, + STEM_A_ID, + STEM_B_ID, + taking_turns, +) + +__all__ = ["taking_turns"] + +VOICE_ID: Final[str] = "turns-id" +FIRST_VOLUME: Final[Envelope[int]] = Envelope[int](items=(5, 5)) +SECOND_VOLUME: Final[Envelope[int]] = Envelope[int](items=(4, 4)) +LATER_VOLUME: Final[Envelope[int]] = Envelope[int](items=(3, 3)) +SOLE_VOLUME: Final[Envelope[int]] = Envelope[int](items=(2,)) +ARPEGGIO: Final[Envelope[int]] = Envelope[int](items=(0, 3)) +RETUNED_FREQUENCY: Final[int] = 50 +LATER_FREQUENCY: Final[int] = 40 + + +def _change(channel_name: ChannelName, feature_key: FeatureKey, envelope: Envelope[int]) -> ChannelChange: + return ChannelChange( + channel_name=channel_name, + feature_key=feature_key, + envelopes={feature_key: envelope}, + initial_pitch=None, + ) + + +def _removal(stem_id: int) -> StemRemovalRequest: + return StemRemovalRequest(stem_id=stem_id, stem_name=str(stem_id)) + + +@dataclass +class Outcome: + """Everything the rewrites reported, in the order they reported it.""" + + edits: List[ReconstructionEdit] = field(default_factory=list) + dropped: int = 0 + failures: List[Exception] = field(default_factory=list) + busy: List[bool] = field(default_factory=list) + + +@pytest.fixture +def regeneration() -> HeldRegeneration: + return HeldRegeneration() + + +@pytest.fixture +def outcome() -> Outcome: + return Outcome() + + +@pytest.fixture +def rewrites( + reconstruction_manager: ReconstructionManager, + regeneration: HeldRegeneration, + outcome: Outcome, + taking_turns: Reconstruction, +) -> ReconstructionRewrites: + """The steps of the two-recording document, each landing edit adopted the way the coordinator adopts it.""" + reconstruction_manager.load_reconstruction_object(taking_turns, name="turns", voice_id=VOICE_ID) + + def adopt(edit: ReconstructionEdit) -> None: + outcome.edits.append(edit) + reconstruction_manager.apply_edited(edit.reconstruction) + + def drop() -> None: + outcome.dropped += 1 + + rewrites = ReconstructionRewrites(reconstruction_manager, regeneration) + rewrites.on_edit = adopt + rewrites.on_dropped = drop + rewrites.on_failed = outcome.failures.append + rewrites.on_busy_changed = outcome.busy.append + return rewrites + + +def _volume_items(reconstruction: Optional[Reconstruction], channel_name: ChannelName) -> Tuple[int, ...]: + """The volumes of the frames a channel sounds.""" + assert reconstruction is not None + return tuple(instruction.volume for instruction in reconstruction.instructions[channel_name] if instruction.on) + + +class TestMergingWhatWaits: + """A change joins the one waiting at the end of the line when both move the same channel, and nothing else.""" + + def test_changes_of_one_channel_waiting_together_rebuild_once( + self, + rewrites: ReconstructionRewrites, + regeneration: HeldRegeneration, + reconstruction_manager: ReconstructionManager, + ) -> None: + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, SECOND_VOLUME)) + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.ARPEGGIO, ARPEGGIO)) + regeneration.finish() + + (merged,) = regeneration.held + assert merged.features.volume == SECOND_VOLUME + assert merged.features.arpeggio == ARPEGGIO + + def test_nothing_merges_into_the_running_rebuild( + self, + rewrites: ReconstructionRewrites, + regeneration: HeldRegeneration, + ) -> None: + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, SECOND_VOLUME)) + + (running,) = regeneration.held + assert running.features.volume == FIRST_VOLUME + regeneration.finish() + assert regeneration.held[0].features.volume == SECOND_VOLUME + + def test_nothing_merges_across_another_channel( + self, + rewrites: ReconstructionRewrites, + regeneration: HeldRegeneration, + ) -> None: + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, SECOND_VOLUME)) + rewrites.request(_change(SOLE_CHANNEL, FeatureKey.VOLUME, SOLE_VOLUME)) + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, LATER_VOLUME)) + + rebuilt: List[Tuple[ChannelName, Envelope[int]]] = [] + while regeneration.held: + rebuilt.append((regeneration.held[0].channel_name, regeneration.held[0].features.volume)) + regeneration.finish() + + assert rebuilt == [ + (SHARED_CHANNEL, FIRST_VOLUME), + (SHARED_CHANNEL, SECOND_VOLUME), + (SOLE_CHANNEL, SOLE_VOLUME), + (SHARED_CHANNEL, LATER_VOLUME), + ] + + def test_a_later_rate_replaces_one_waiting( + self, + rewrites: ReconstructionRewrites, + regeneration: HeldRegeneration, + outcome: Outcome, + ) -> None: + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) + rewrites.request(RateChange(nes_frequency=RETUNED_FREQUENCY)) + rewrites.request(RateChange(nes_frequency=LATER_FREQUENCY)) + regeneration.finish() + + retunes = [edit for edit in outcome.edits if isinstance(edit, Retune)] + assert [retune.nes_frequency for retune in retunes] == [LATER_FREQUENCY] + + +class TestEachStepReadsTheDocumentAtItsTurn: + """A step is built from the document the step before it left, and from the listening as it stands.""" + + def test_a_rebuild_starts_from_what_the_step_before_it_left( + self, + rewrites: ReconstructionRewrites, + regeneration: HeldRegeneration, + reconstruction_manager: ReconstructionManager, + ) -> None: + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) + rewrites.request(_change(SOLE_CHANNEL, FeatureKey.VOLUME, SOLE_VOLUME)) + regeneration.finish() + + (second,) = regeneration.held + assert second.reconstruction is reconstruction_manager.reconstruction + assert _volume_items(second.reconstruction, SHARED_CHANNEL) == FIRST_VOLUME.items + + def test_the_dimensions_a_change_leaves_are_read_from_the_document( + self, + rewrites: ReconstructionRewrites, + regeneration: HeldRegeneration, + ) -> None: + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.ARPEGGIO, ARPEGGIO)) + regeneration.finish() + + (arpeggio,) = regeneration.held + assert arpeggio.features.volume.items[: len(FIRST_VOLUME.items)] == FIRST_VOLUME.items + + def test_the_recordings_heard_are_read_at_the_changes_turn( + self, + rewrites: ReconstructionRewrites, + regeneration: HeldRegeneration, + reconstruction_manager: ReconstructionManager, + ) -> None: + rewrites.request(_change(SOLE_CHANNEL, FeatureKey.VOLUME, SOLE_VOLUME)) + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) + reconstruction_manager.listening.set_channels(STEM_B_ID, frozenset({SOLE_CHANNEL})) + regeneration.finish() + + (shared,) = regeneration.held + assert shared.heard == frozenset({STEM_A_ID}) + + +class TestAStepThatNoLongerApplies: + """A removal or a rate the document already carries is let go at its turn.""" + + def test_a_recording_a_step_before_took_out_is_skipped( + self, + rewrites: ReconstructionRewrites, + regeneration: HeldRegeneration, + outcome: Outcome, + ) -> None: + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) + rewrites.request(_removal(STEM_B_ID)) + rewrites.request(_removal(STEM_B_ID)) + regeneration.finish() + + assert [type(edit) for edit in outcome.edits] == [ChannelEdit, StemRemoval] + + def test_the_last_recording_is_skipped( + self, + rewrites: ReconstructionRewrites, + outcome: Outcome, + reconstruction_manager: ReconstructionManager, + ) -> None: + rewrites.request(_removal(STEM_B_ID)) + rewrites.request(_removal(STEM_A_ID)) + + assert [type(edit) for edit in outcome.edits] == [StemRemoval] + reconstruction = reconstruction_manager.reconstruction + assert reconstruction is not None + assert list(reconstruction.stems_data.config.entries_by_id) == [STEM_A_ID] + + def test_a_rate_the_document_runs_at_is_skipped( + self, + rewrites: ReconstructionRewrites, + outcome: Outcome, + taking_turns: Reconstruction, + ) -> None: + rewrites.request(RateChange(nes_frequency=taking_turns.config.nes_frequency)) + + assert outcome.edits == [] + + def test_another_rate_retunes_the_document( + self, + rewrites: ReconstructionRewrites, + outcome: Outcome, + reconstruction_manager: ReconstructionManager, + ) -> None: + rewrites.request(RateChange(nes_frequency=RETUNED_FREQUENCY)) + + (retune,) = outcome.edits + assert isinstance(retune, Retune) + assert retune.nes_frequency == RETUNED_FREQUENCY + assert retune.reconstruction.config.nes_frequency == RETUNED_FREQUENCY + + def test_a_change_with_no_document_open_is_let_go( + self, + rewrites: ReconstructionRewrites, + regeneration: HeldRegeneration, + reconstruction_manager: ReconstructionManager, + outcome: Outcome, + ) -> None: + reconstruction_manager.close_reconstruction() + + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) + rewrites.request(_removal(STEM_B_ID)) + rewrites.request(RateChange(nes_frequency=RETUNED_FREQUENCY)) + + assert regeneration.held == () + assert outcome.edits == [] + assert not rewrites.is_busy + + +class TestAResultLandsOnItsOwnDocument: + """A rebuild's result lands only on the document it was computed from.""" + + def test_a_result_for_the_open_document_lands( + self, + rewrites: ReconstructionRewrites, + regeneration: HeldRegeneration, + outcome: Outcome, + ) -> None: + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) + regeneration.finish() + + (edit,) = outcome.edits + assert isinstance(edit, ChannelEdit) + assert (edit.channel_name, edit.feature_key) == (SHARED_CHANNEL, FeatureKey.VOLUME) + + def test_a_result_for_a_document_put_away_is_dropped_and_the_panel_redrawn( + self, + rewrites: ReconstructionRewrites, + regeneration: HeldRegeneration, + reconstruction_manager: ReconstructionManager, + outcome: Outcome, + reconstruction_factory: ReconstructionFactory, + ) -> None: + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) + reconstruction_manager.load_reconstruction_object(reconstruction_factory(), name="other", voice_id="other") + regeneration.finish() + + assert outcome.edits == [] + assert outcome.dropped == 1 + + def test_a_failed_rebuild_is_reported_and_the_next_step_runs( + self, + rewrites: ReconstructionRewrites, + regeneration: HeldRegeneration, + outcome: Outcome, + ) -> None: + failure = RuntimeError("synthesis failed") + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) + rewrites.request(_change(SOLE_CHANNEL, FeatureKey.VOLUME, SOLE_VOLUME)) + + regeneration.fail(failure) + + assert outcome.failures == [failure] + assert outcome.dropped == 1 + assert regeneration.held[0].channel_name is SOLE_CHANNEL + + def test_a_result_with_no_rebuild_running_is_refused( + self, + rewrites: ReconstructionRewrites, + taking_turns: Reconstruction, + ) -> None: + stray = HeldRegeneration() + stray.subscribe(rewrites._on_result) + stray.start(taking_turns, SHARED_CHANNEL, taking_turns.export()[SHARED_CHANNEL], frozenset()) + + with pytest.raises(RuntimeError, match="no rebuild was running"): + stray.finish() + + +class TestDroppingTheLine: + """An outside replacement lets the edits meant for the document it puts away go.""" + + def test_the_waiting_edits_leave_and_the_running_result_lands_nowhere( + self, + rewrites: ReconstructionRewrites, + regeneration: HeldRegeneration, + outcome: Outcome, + ) -> None: + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) + rewrites.request(_change(SOLE_CHANNEL, FeatureKey.VOLUME, SOLE_VOLUME)) + rewrites.request(RateChange(nes_frequency=RETUNED_FREQUENCY)) + + rewrites.drop() + regeneration.finish() + + assert outcome.edits == [] + assert outcome.dropped == 0 + assert regeneration.held == () + assert not rewrites.is_busy + + def test_a_waiting_gesture_keeps_its_place( + self, + rewrites: ReconstructionRewrites, + regeneration: HeldRegeneration, + ) -> None: + """A save or an undo the reader asked for still happens, on the document now open.""" + gesture = MagicMock() + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) + rewrites.request(AfterEdits(gesture=gesture)) + + rewrites.drop() + gesture.assert_not_called() + regeneration.finish() + + gesture.assert_called_once_with() + + +class TestThePanelDrawsWhatTheDocumentWillHold: + """The changes on their way are written over the document's envelopes, in the order they were made.""" + + def test_the_running_and_the_waiting_changes_are_drawn( + self, + rewrites: ReconstructionRewrites, + reconstruction_manager: ReconstructionManager, + ) -> None: + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) + rewrites.request(_change(SOLE_CHANNEL, FeatureKey.VOLUME, SOLE_VOLUME)) + document = reconstruction_manager.current_features + assert document is not None + + drawn = rewrites.drawn(document) + + assert drawn[SHARED_CHANNEL].volume == FIRST_VOLUME + assert drawn[SOLE_CHANNEL].volume == SOLE_VOLUME + assert drawn.ownership == document.ownership + + def test_a_later_change_of_a_dimension_is_drawn_over_an_earlier_one( + self, + rewrites: ReconstructionRewrites, + reconstruction_manager: ReconstructionManager, + ) -> None: + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, LATER_VOLUME)) + document = reconstruction_manager.current_features + assert document is not None + + assert rewrites.drawn(document)[SHARED_CHANNEL].volume == LATER_VOLUME + + def test_the_drawing_leaves_the_line_as_it_stands( + self, + rewrites: ReconstructionRewrites, + regeneration: HeldRegeneration, + reconstruction_manager: ReconstructionManager, + ) -> None: + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) + document = reconstruction_manager.current_features + assert document is not None + + rewrites.drawn(document) + + assert len(regeneration.held) == 1 + assert rewrites.is_busy + + def test_a_document_put_away_draws_nothing_of_its_rebuild( + self, + rewrites: ReconstructionRewrites, + reconstruction_manager: ReconstructionManager, + taking_turns: Reconstruction, + ) -> None: + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) + reconstruction_manager.load_reconstruction_object(taking_turns.model_copy(), name="copy", voice_id="copy") + document = reconstruction_manager.current_features + assert document is not None + + assert rewrites.drawn(document) == document + + def test_nothing_on_its_way_draws_the_document( + self, + rewrites: ReconstructionRewrites, + reconstruction_manager: ReconstructionManager, + ) -> None: + document = reconstruction_manager.current_features + assert document is not None + + assert rewrites.drawn(document) is document + + +class TestAStepThatReshapesTheDocument: + """A change drawn while a removal or a whole-document gesture waits is refused, and the panel redrawn once the line empties.""" + + @pytest.mark.parametrize("reshaping", ("removal", "gesture")) + def test_the_change_is_refused( + self, + reshaping: str, + rewrites: ReconstructionRewrites, + regeneration: HeldRegeneration, + ) -> None: + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) + rewrites.request(_removal(STEM_B_ID) if reshaping == "removal" else AfterEdits(gesture=MagicMock())) + rewrites.request(_change(SOLE_CHANNEL, FeatureKey.VOLUME, SOLE_VOLUME)) + regeneration.finish() + + assert regeneration.held == () + + @pytest.mark.parametrize("reshaping", ("removal", "gesture")) + def test_the_panel_is_redrawn_once_the_line_empties( + self, + reshaping: str, + rewrites: ReconstructionRewrites, + regeneration: HeldRegeneration, + outcome: Outcome, + ) -> None: + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) + rewrites.request(_removal(STEM_B_ID) if reshaping == "removal" else AfterEdits(gesture=MagicMock())) + rewrites.request(_change(SOLE_CHANNEL, FeatureKey.VOLUME, SOLE_VOLUME)) + assert outcome.dropped == 0 + + regeneration.finish() + + assert outcome.dropped == 1 + + def test_a_change_after_the_step_has_run_is_taken( + self, + rewrites: ReconstructionRewrites, + regeneration: HeldRegeneration, + ) -> None: + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) + rewrites.request(_removal(STEM_B_ID)) + regeneration.finish() + + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, LATER_VOLUME)) + + assert len(regeneration.held) == 1 + + +class TestTheBusySpan: + """The line reports filling as the first step waits or runs, and emptying once the last one lands.""" + + def test_a_rebuild_fills_the_line_until_it_lands( + self, + rewrites: ReconstructionRewrites, + regeneration: HeldRegeneration, + outcome: Outcome, + ) -> None: + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) + rewrites.request(_change(SOLE_CHANNEL, FeatureKey.VOLUME, SOLE_VOLUME)) + assert rewrites.is_busy + regeneration.finish() + assert outcome.busy == [True] + + regeneration.finish() + + assert outcome.busy == [True, False] + assert not rewrites.is_busy + + def test_steps_taken_at_once_leave_the_line_empty( + self, + rewrites: ReconstructionRewrites, + outcome: Outcome, + ) -> None: + gesture = MagicMock() + + rewrites.request(_removal(STEM_B_ID)) + rewrites.request(AfterEdits(gesture=gesture)) + + gesture.assert_called_once_with() + assert outcome.busy == [] diff --git a/tests/unit/sampletones_application/logic/reconstruction/rewrites/test_steps.py b/tests/unit/sampletones_application/logic/reconstruction/rewrites/test_steps.py new file mode 100644 index 000000000..53a7de5ea --- /dev/null +++ b/tests/unit/sampletones_application/logic/reconstruction/rewrites/test_steps.py @@ -0,0 +1,118 @@ +from typing import Final + +import pytest + +from sampletones_application.logic.reconstruction.rewrites.steps import ChannelChange +from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.exporters import Features +from sampletones_core.features.envelope import Envelope +from sampletones_core.reconstructions import Reconstruction +from tests.suite.stems import SHARED_CHANNEL, SOLE_CHANNEL, taking_turns + +__all__ = ["taking_turns"] + +FIRST_VOLUME: Final[Envelope[int]] = Envelope[int](items=(5, 5)) +LATER_VOLUME: Final[Envelope[int]] = Envelope[int](items=(3, 3)) +ARPEGGIO: Final[Envelope[int]] = Envelope[int](items=(0, 3), loop_point=1) +FIRST_PITCH: Final[int] = 62 +LATER_PITCH: Final[int] = 64 + + +def _change( + channel_name: ChannelName, + feature_key: FeatureKey, + envelope: Envelope[int], +) -> ChannelChange: + return ChannelChange( + channel_name=channel_name, + feature_key=feature_key, + envelopes={feature_key: envelope}, + initial_pitch=None, + ) + + +def _pitch(value: int) -> ChannelChange: + return ChannelChange( + channel_name=SHARED_CHANNEL, + feature_key=FeatureKey.INITIAL_PITCH, + envelopes={}, + initial_pitch=value, + ) + + +@pytest.fixture +def features(taking_turns: Reconstruction) -> Features: + """The shared channel's envelopes as the document holds them.""" + return taking_turns.export()[SHARED_CHANNEL] + + +class TestMergingTwoChanges: + """Two changes of one channel make one, carrying what both moved, the later winning where both moved.""" + + def test_the_later_value_of_a_dimension_wins(self) -> None: + merged = _change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME).merged( + _change(SHARED_CHANNEL, FeatureKey.VOLUME, LATER_VOLUME) + ) + + assert merged.envelopes == {FeatureKey.VOLUME: LATER_VOLUME} + + def test_dimensions_moved_apart_are_both_carried(self) -> None: + merged = _change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME).merged( + _change(SHARED_CHANNEL, FeatureKey.ARPEGGIO, ARPEGGIO) + ) + + assert merged.envelopes == {FeatureKey.VOLUME: FIRST_VOLUME, FeatureKey.ARPEGGIO: ARPEGGIO} + + def test_the_dimension_moved_last_names_the_change(self) -> None: + merged = _change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME).merged( + _change(SHARED_CHANNEL, FeatureKey.ARPEGGIO, ARPEGGIO) + ) + + assert merged.feature_key is FeatureKey.ARPEGGIO + + def test_a_later_pitch_wins(self) -> None: + assert _pitch(FIRST_PITCH).merged(_pitch(LATER_PITCH)).initial_pitch == LATER_PITCH + + def test_a_pitch_stands_through_a_later_change_that_leaves_it(self) -> None: + merged = _pitch(FIRST_PITCH).merged(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) + + assert merged.initial_pitch == FIRST_PITCH + assert merged.envelopes == {FeatureKey.VOLUME: FIRST_VOLUME} + + def test_a_change_of_another_channel_is_refused(self) -> None: + with pytest.raises(ValueError, match="merges into a change of that channel alone"): + _change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME).merged( + _change(SOLE_CHANNEL, FeatureKey.VOLUME, LATER_VOLUME) + ) + + +class TestWritingAChangeOverTheDocument: + """A change writes what the reader moved over the envelopes the document holds at its turn.""" + + def test_a_moved_dimension_takes_the_readers_value(self, features: Features) -> None: + rebased = _change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME).rebased(features) + + assert rebased.volume == FIRST_VOLUME + + def test_the_dimensions_left_alone_are_read_from_the_document(self, features: Features) -> None: + document = features.with_envelope(FeatureKey.ARPEGGIO, ARPEGGIO) + + rebased = _change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME).rebased(document) + + assert rebased.arpeggio == ARPEGGIO + assert rebased.duty_cycle == document.duty_cycle + assert rebased.initial_pitch == document.initial_pitch + + def test_a_moved_pitch_is_written(self, features: Features) -> None: + rebased = _pitch(LATER_PITCH).rebased(features) + + assert rebased.initial_pitch == LATER_PITCH + assert rebased.envelopes == features.envelopes + + def test_a_merged_change_writes_what_both_moved(self, features: Features) -> None: + merged = _change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME).merged(_pitch(LATER_PITCH)) + + rebased = merged.rebased(features) + + assert rebased.volume == FIRST_VOLUME + assert rebased.initial_pitch == LATER_PITCH diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_data.py b/tests/unit/sampletones_application/logic/reconstruction/test_data.py index b82968640..39af17c93 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_data.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_data.py @@ -505,6 +505,20 @@ def test_an_edit_keeping_every_entry_keeps_every_recording( assert rebound.stem_audios == data.stem_audios + def test_a_document_naming_a_recording_the_load_lost_holds_none( + self, + reconstruction_factory: Callable[[], Reconstruction], + tmp_path: Path, + ) -> None: + """A restore can bring back an entry this document let go of, whose audio it no longer holds.""" + data = self._three_recordings(reconstruction_factory, tmp_path) + remaining = data.with_reconstruction(without_stem(data.reconstruction, 1)) + + restored = remaining.with_reconstruction(data.reconstruction) + + assert restored.stem_audios == () + assert restored.original_audio is None + class TestWaveformData: def test_projects_the_render_relevant_fields( diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_editor.py b/tests/unit/sampletones_application/logic/reconstruction/test_editor.py index b9b7b2490..b080ef4c9 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_editor.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_editor.py @@ -142,6 +142,50 @@ def test_an_instrument_removed_from_the_project_leaves_the_tab_holding_nothing( assert editor.edited_instrument() is None +class TestWhetherTheTabHoldsAnInstrument: + """The tab keeps the instrument it was given until it lets go, so a departed voice is noticed.""" + + def test_it_holds_none_to_begin_with(self, editor: InstrumentEditor) -> None: + assert not editor.holds_instrument + + def test_opening_an_instrument_holds_it( + self, + editor: InstrumentEditor, + controller: ProjectController, + ) -> None: + instrument = controller.add_instrument(new_instrument("lead")) + + editor.edit_instrument(instrument.id) + + assert editor.holds_instrument + + def test_an_instrument_removed_from_the_project_is_still_held( + self, + editor: InstrumentEditor, + controller: ProjectController, + ) -> None: + """Whoever follows the project reads this to tell a departed instrument from none at all.""" + instrument = controller.add_instrument(new_instrument("lead")) + editor.edit_instrument(instrument.id) + + controller.remove_voice(instrument.id) + + assert editor.holds_instrument + assert editor.instrument is None + + def test_letting_go_holds_none( + self, + editor: InstrumentEditor, + controller: ProjectController, + ) -> None: + instrument = controller.add_instrument(new_instrument("lead")) + editor.edit_instrument(instrument.id) + + editor.release_instrument() + + assert not editor.holds_instrument + + class TestWritingIntoTheInstrument: def test_an_envelope_reaches_the_instrument( self, diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py b/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py index 80527c754..57ae679cc 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py @@ -4,7 +4,6 @@ import pytest from sampletones_application.constants.instruments import INSTRUMENT_CHANNEL -from sampletones_application.layout.behavior.scheduling.scheduling import SchedulingBehavior from sampletones_application.logic.history.manager import HistoryManager from sampletones_application.logic.project.controller import ProjectController from sampletones_application.logic.project.manager import ProjectManager @@ -14,6 +13,8 @@ ReconstructionInstrumentsLogic, ) from sampletones_application.logic.reconstruction.manager import ReconstructionManager +from sampletones_application.logic.reconstruction.rewrites.queue import ReconstructionRewrites +from sampletones_application.logic.reconstruction.rewrites.steps import ChannelChange from sampletones_application.view_model.reconstruction.envelopes import ( ChannelEnvelopesViewModel, ) @@ -21,7 +22,7 @@ ReconstructionInstrumentsViewModel, ) from sampletones_core.constants.enums import ChannelName, FeatureKey -from sampletones_core.exporters import CHANNEL_TO_EXPORTER_MAP, Features +from sampletones_core.exporters import Features from sampletones_core.features.envelope import Envelope from sampletones_core.formats.famitracker.footprint import ( features_footprint, @@ -30,7 +31,11 @@ ) from sampletones_core.project.voices.creation import new_instrument from sampletones_core.reconstructions import Reconstruction -from tests.suite.stems import everything_heard +from tests.suite.application import HeldQueue, held_queue +from tests.suite.regeneration import HeldRegeneration +from tests.suite.stems import SHARED_CHANNEL, SOLE_CHANNEL, everything_heard, regenerated, taking_turns + +__all__ = ["held_queue", "taking_turns"] def _heard_features(reconstruction: Reconstruction) -> ChannelEnvelopesViewModel: @@ -48,19 +53,6 @@ def _silent_volume(features: Features) -> Envelope[int]: return Envelope[int](items=(0,) * features.frame_count) -def _regenerated(reconstruction: Reconstruction, channel_name: ChannelName, features: Features) -> Reconstruction: - """The document a regeneration leaves once it rebuilds one channel from ``features``.""" - regenerated = reconstruction.model_copy(deep=True) - regenerated.update_channel_data( - channel_name, - list(CHANNEL_TO_EXPORTER_MAP[channel_name].from_features(features)), - features.initial_pitch, - features.held_features, - heard=regenerated.recorded_stem_ids, - ) - return regenerated - - def _editor( reconstruction_manager: MagicMock, controller: ProjectController, @@ -85,15 +77,33 @@ def instrument_editor(mock_reconstruction_manager: MagicMock) -> InstrumentEdito return _editor(mock_reconstruction_manager, ProjectController(ProjectManager())) +@pytest.fixture +def regeneration() -> HeldRegeneration: + return HeldRegeneration() + + +@pytest.fixture +def rewrites( + mock_reconstruction_manager: MagicMock, + regeneration: HeldRegeneration, +) -> ReconstructionRewrites: + """The steps of the open document, each rebuild held until a case lands it.""" + return ReconstructionRewrites(mock_reconstruction_manager, regeneration) + + +def _logic(editor: InstrumentEditor, rewrites: ReconstructionRewrites) -> ReconstructionInstrumentsLogic: + """The panel's logic, sending every change on to the document's steps the way the tab wires it.""" + logic = ReconstructionInstrumentsLogic(editor, rewrites) + logic.on_channel_changed = rewrites.request + return logic + + @pytest.fixture def instruments_logic( instrument_editor: InstrumentEditor, - scheduling: SchedulingBehavior, + rewrites: ReconstructionRewrites, ) -> ReconstructionInstrumentsLogic: - return ReconstructionInstrumentsLogic( - instrument_editor, - scheduling=scheduling, - ) + return _logic(instrument_editor, rewrites) class TestReconstructionInstrumentsLogicUpdateDisplay: @@ -298,16 +308,17 @@ def test_an_edit_writing_every_frame_silent_is_measured_standing_by( instruments_logic.handle_envelope_changed(SILENCED_CHANNEL, FeatureKey.VOLUME, silenced.volume) - regenerated = _regenerated(reconstruction, SILENCED_CHANNEL, silenced) + landed = regenerated(reconstruction, SILENCED_CHANNEL, silenced) footprint = received[0].footprint assert footprint is not None assert SILENCED_CHANNEL not in received[0].playing_channels assert footprint.bytes_for(SILENCED_CHANNEL) is None - assert footprint.total_bytes == total_footprint(reconstruction_footprints(regenerated).values()).total_bytes + assert footprint.total_bytes == total_footprint(reconstruction_footprints(landed).values()).total_bytes def test_an_edit_standing_a_channel_by_redraws_it_empty( self, instruments_logic: ReconstructionInstrumentsLogic, + regeneration: HeldRegeneration, mock_reconstruction_manager: MagicMock, reconstruction: Reconstruction, silenced: Features, @@ -318,7 +329,9 @@ def test_an_edit_standing_a_channel_by_redraws_it_empty( instruments_logic.on_feature_data_changed = feature_updates.append instruments_logic.handle_envelope_changed(SILENCED_CHANNEL, FeatureKey.VOLUME, silenced.volume) - landed = _heard_features(_regenerated(reconstruction, SILENCED_CHANNEL, silenced)) + rebuilt = regenerated(reconstruction, SILENCED_CHANNEL, silenced) + regeneration.finish_with(rebuilt) + landed = _heard_features(rebuilt) mock_reconstruction_manager.current_features = landed instruments_logic.refresh_view() @@ -330,6 +343,7 @@ def test_an_edit_standing_a_channel_by_redraws_it_empty( def test_the_channel_is_redrawn_once( self, instruments_logic: ReconstructionInstrumentsLogic, + regeneration: HeldRegeneration, mock_reconstruction_manager: MagicMock, reconstruction: Reconstruction, silenced: Features, @@ -338,9 +352,9 @@ def test_the_channel_is_redrawn_once( feature_updates: List[Optional[ChannelEnvelopesViewModel]] = [] instruments_logic.on_feature_data_changed = feature_updates.append instruments_logic.handle_envelope_changed(SILENCED_CHANNEL, FeatureKey.VOLUME, silenced.volume) - mock_reconstruction_manager.current_features = _heard_features( - _regenerated(reconstruction, SILENCED_CHANNEL, silenced) - ) + rebuilt = regenerated(reconstruction, SILENCED_CHANNEL, silenced) + regeneration.finish_with(rebuilt) + mock_reconstruction_manager.current_features = _heard_features(rebuilt) instruments_logic.refresh_view() instruments_logic.refresh_view() @@ -350,6 +364,7 @@ def test_the_channel_is_redrawn_once( def test_an_edit_that_sounds_again_before_it_lands_leaves_the_envelopes_displayed( self, instruments_logic: ReconstructionInstrumentsLogic, + regeneration: HeldRegeneration, mock_reconstruction_manager: MagicMock, reconstruction: Reconstruction, silenced: Features, @@ -365,9 +380,9 @@ def test_an_edit_that_sounds_again_before_it_lands_leaves_the_envelopes_displaye FeatureKey.VOLUME, features[SILENCED_CHANNEL].volume, ) - mock_reconstruction_manager.current_features = _heard_features( - _regenerated(reconstruction, SILENCED_CHANNEL, silenced) - ) + rebuilt = regenerated(reconstruction, SILENCED_CHANNEL, silenced) + regeneration.finish_with(rebuilt) + mock_reconstruction_manager.current_features = _heard_features(rebuilt) instruments_logic.refresh_view() @@ -375,7 +390,7 @@ def test_an_edit_that_sounds_again_before_it_lands_leaves_the_envelopes_displaye class TestReconstructionInstrumentsLogicHandlePitchValueChanged: - def test_schedules_reconstruction_update( + def test_a_moved_pitch_travels_on_as_a_change( self, instruments_logic: ReconstructionInstrumentsLogic, mock_reconstruction_manager: MagicMock, @@ -383,30 +398,31 @@ def test_schedules_reconstruction_update( ) -> None: mock_reconstruction_manager.current_features = _heard_features(reconstruction_factory()) callback = MagicMock() - instruments_logic.on_reconstruction_instrument_updated = callback + instruments_logic.on_channel_changed = callback instruments_logic.handle_pitch_value_changed(ChannelName.PULSE1, NEW_PITCH) callback.assert_called_once() - def test_forwards_generator_pitch_feature_and_value( + def test_the_change_carries_the_channel_and_the_pitch_alone( self, instruments_logic: ReconstructionInstrumentsLogic, mock_reconstruction_manager: MagicMock, reconstruction_factory: Callable[[], Reconstruction], ) -> None: mock_reconstruction_manager.current_features = _heard_features(reconstruction_factory()) - callback = MagicMock() - instruments_logic.on_reconstruction_instrument_updated = callback + received: List[ChannelChange] = [] + instruments_logic.on_channel_changed = received.append instruments_logic.handle_pitch_value_changed(ChannelName.PULSE1, NEW_PITCH) - channel_name, feature_key, features = callback.call_args.args - assert channel_name == ChannelName.PULSE1 - assert feature_key == FeatureKey.INITIAL_PITCH - assert features.initial_pitch == NEW_PITCH + (change,) = received + assert change.channel_name == ChannelName.PULSE1 + assert change.feature_key == FeatureKey.INITIAL_PITCH + assert change.initial_pitch == NEW_PITCH + assert change.envelopes == {} class TestReconstructionInstrumentsLogicHandleEnvelope: - def test_an_edited_envelope_schedules_an_update( + def test_an_edited_envelope_travels_on_as_a_change( self, instruments_logic: ReconstructionInstrumentsLogic, mock_reconstruction_manager: MagicMock, @@ -414,7 +430,7 @@ def test_an_edited_envelope_schedules_an_update( ) -> None: mock_reconstruction_manager.current_features = _heard_features(reconstruction_factory()) callback = MagicMock() - instruments_logic.on_reconstruction_instrument_updated = callback + instruments_logic.on_channel_changed = callback instruments_logic.handle_envelope_changed( ChannelName.PULSE1, FeatureKey.VOLUME, @@ -428,12 +444,10 @@ def test_an_edited_envelope_keeps_the_point_it_was_given( mock_reconstruction_manager: MagicMock, reconstruction_factory: Callable[[], Reconstruction], ) -> None: - """The panel states values and loop point together, so the update carries both.""" + """The panel states values and loop point together, so the change carries both.""" mock_reconstruction_manager.current_features = _heard_features(reconstruction_factory()) - received: List[Features] = [] - instruments_logic.on_reconstruction_instrument_updated = lambda _channel, _key, features: received.append( - features - ) + received: List[ChannelChange] = [] + instruments_logic.on_channel_changed = received.append arpeggio = Envelope[int](items=(0, 4, 7), loop_point=1) instruments_logic.handle_envelope_changed( @@ -442,19 +456,25 @@ def test_an_edited_envelope_keeps_the_point_it_was_given( arpeggio, ) - assert received[0].arpeggio == arpeggio - + assert received[0].envelopes == {FeatureKey.ARPEGGIO: arpeggio} -class TestReconstructionInstrumentsLogicOnUpdateScheduled: - def test_no_pending_update_is_a_no_op( + def test_the_change_carries_the_dimension_moved_alone( self, instruments_logic: ReconstructionInstrumentsLogic, + mock_reconstruction_manager: MagicMock, + reconstruction_factory: Callable[[], Reconstruction], ) -> None: - callback = MagicMock() - instruments_logic.on_reconstruction_instrument_updated = callback - instruments_logic._pending_reconstruction_update = None - instruments_logic._on_reconstruction_update_scheduled() - callback.assert_not_called() + """The rest of the channel is read at the change's turn, so a step before it stands.""" + mock_reconstruction_manager.current_features = _heard_features(reconstruction_factory()) + received: List[ChannelChange] = [] + instruments_logic.on_channel_changed = received.append + + instruments_logic.handle_envelope_changed(ChannelName.PULSE1, FeatureKey.VOLUME, Envelope[int](items=(7,))) + + (change,) = received + assert change.feature_key == FeatureKey.VOLUME + assert list(change.envelopes) == [FeatureKey.VOLUME] + assert change.initial_pitch is None class TestTheInstrumentsPanelShowsAnInstrument: @@ -469,14 +489,14 @@ def instrument_logic( self, mock_reconstruction_manager: MagicMock, project_controller: ProjectController, - scheduling: SchedulingBehavior, + rewrites: ReconstructionRewrites, ) -> ReconstructionInstrumentsLogic: mock_reconstruction_manager.current_features = None editor = _editor(mock_reconstruction_manager, project_controller) instrument = project_controller.add_instrument(new_instrument("lead")) project_controller.set_instrument_envelope(instrument.id, FeatureKey.VOLUME, Envelope(items=(15, 12))) editor.edit_instrument(instrument.id) - return ReconstructionInstrumentsLogic(editor, scheduling=scheduling) + return _logic(editor, rewrites) def test_the_view_names_the_instrument_it_shows( self, @@ -538,7 +558,7 @@ def test_an_envelope_edit_reaches_the_instrument_without_a_regeneration( project_controller: ProjectController, ) -> None: regenerated: List[object] = [] - instrument_logic.on_reconstruction_instrument_updated = lambda *args: regenerated.append(args) + instrument_logic.on_channel_changed = regenerated.append instrument_logic.handle_envelope_changed( INSTRUMENT_CHANNEL, @@ -563,3 +583,168 @@ def test_the_figure_measures_the_one_instrument_it_exports( instrument = project_controller.project.voices[0] assert received[-1].footprint is not None assert received[-1].footprint.total_bytes == features_footprint(instrument.instrument_features()).total_bytes + + +class TestAnEditReachingAVoiceThatLeft: + """A gesture the panel sent before its voice left draws the panel as it now stands and goes nowhere.""" + + @pytest.fixture + def project_controller(self) -> ProjectController: + return ProjectController(ProjectManager()) + + @pytest.fixture + def left_logic( + self, + mock_reconstruction_manager: MagicMock, + project_controller: ProjectController, + rewrites: ReconstructionRewrites, + ) -> ReconstructionInstrumentsLogic: + """The panel's logic over an instrument the project has since removed.""" + mock_reconstruction_manager.current_features = None + editor = _editor(mock_reconstruction_manager, project_controller) + instrument = project_controller.add_instrument(new_instrument("lead")) + editor.edit_instrument(instrument.id) + project_controller.remove_voice(instrument.id) + return _logic(editor, rewrites) + + def test_an_envelope_edit_draws_the_panel_empty( + self, + left_logic: ReconstructionInstrumentsLogic, + ) -> None: + views: List[ReconstructionInstrumentsViewModel] = [] + left_logic.on_view_changed = views.append + + left_logic.handle_envelope_changed(INSTRUMENT_CHANNEL, FeatureKey.VOLUME, Envelope[int](items=(7,))) + + assert len(views) == 1 + assert views[0].instrument is None + assert not views[0].reconstruction_loaded + + def test_an_envelope_edit_goes_nowhere( + self, + left_logic: ReconstructionInstrumentsLogic, + ) -> None: + regenerated: List[object] = [] + left_logic.on_channel_changed = regenerated.append + + left_logic.handle_envelope_changed(INSTRUMENT_CHANNEL, FeatureKey.VOLUME, Envelope[int](items=(7,))) + + assert regenerated == [] + + def test_a_pitch_change_draws_the_panel_empty( + self, + left_logic: ReconstructionInstrumentsLogic, + ) -> None: + views: List[ReconstructionInstrumentsViewModel] = [] + left_logic.on_view_changed = views.append + regenerated: List[object] = [] + left_logic.on_channel_changed = regenerated.append + + left_logic.handle_pitch_value_changed(ChannelName.PULSE1, NEW_PITCH) + + assert len(views) == 1 + assert views[0].instrument is None + assert regenerated == [] + + def test_an_envelope_edit_after_the_document_closed_goes_nowhere( + self, + instruments_logic: ReconstructionInstrumentsLogic, + mock_reconstruction_manager: MagicMock, + ) -> None: + """A close lands on the panel a moment later, so an edit can reach it in between.""" + mock_reconstruction_manager.current_features = None + received: List[Optional[ChannelEnvelopesViewModel]] = [] + instruments_logic.on_feature_data_changed = received.append + regenerated: List[object] = [] + instruments_logic.on_channel_changed = regenerated.append + + instruments_logic.handle_envelope_changed(ChannelName.PULSE1, FeatureKey.VOLUME, Envelope[int](items=(7,))) + + assert received == [None] + assert regenerated == [] + + def test_a_pitch_change_reaching_an_instrument_goes_nowhere( + self, + mock_reconstruction_manager: MagicMock, + project_controller: ProjectController, + rewrites: ReconstructionRewrites, + ) -> None: + """The panel offers the pitch on a reconstruction's channels alone.""" + mock_reconstruction_manager.current_features = None + editor = _editor(mock_reconstruction_manager, project_controller) + editor.edit_instrument(project_controller.add_instrument(new_instrument("lead")).id) + logic = _logic(editor, rewrites) + views: List[ReconstructionInstrumentsViewModel] = [] + logic.on_view_changed = views.append + regenerated: List[object] = [] + logic.on_channel_changed = regenerated.append + + logic.handle_pitch_value_changed(INSTRUMENT_CHANNEL, NEW_PITCH) + + assert regenerated == [] + assert len(views) == 1 + assert views[0].instrument is not None + + +class TestEveryGestureReachesTheDocument: + """Each gesture the panel makes travels on to the document, however quickly the next one follows.""" + + def test_two_channels_edited_within_one_frame_both_reach_the_document( + self, + instruments_logic: ReconstructionInstrumentsLogic, + mock_reconstruction_manager: MagicMock, + held_queue: HeldQueue, + taking_turns: Reconstruction, + ) -> None: + mock_reconstruction_manager.current_features = _heard_features(taking_turns) + reached: List[ChannelName] = [] + instruments_logic.on_channel_changed = lambda change: reached.append(change.channel_name) + + instruments_logic.handle_envelope_changed(SHARED_CHANNEL, FeatureKey.VOLUME, Envelope[int](items=(5, 5))) + instruments_logic.handle_envelope_changed(SOLE_CHANNEL, FeatureKey.VOLUME, Envelope[int](items=(3,))) + held_queue.drain() + + assert reached == [SHARED_CHANNEL, SOLE_CHANNEL] + + def test_a_change_waiting_behind_a_rebuild_is_measured( + self, + instruments_logic: ReconstructionInstrumentsLogic, + mock_reconstruction_manager: MagicMock, + taking_turns: Reconstruction, + ) -> None: + """The figures answer for what the document will hold, a change still waiting included.""" + mock_reconstruction_manager.current_features = _heard_features(taking_turns) + views: List[ReconstructionInstrumentsViewModel] = [] + sole = Envelope[int](items=(3, 3, 3)) + instruments_logic.handle_envelope_changed(SHARED_CHANNEL, FeatureKey.VOLUME, Envelope[int](items=(5, 5))) + instruments_logic.handle_envelope_changed(SOLE_CHANNEL, FeatureKey.VOLUME, sole) + instruments_logic.on_view_changed = views.append + + instruments_logic.refresh_view() + + edited = _heard_features(taking_turns)[SOLE_CHANNEL].with_envelope(FeatureKey.VOLUME, sole) + footprint = views[-1].footprint + assert footprint is not None + assert footprint.bytes_for(SOLE_CHANNEL) == features_footprint(edited).total_bytes + + def test_a_redraw_draws_the_changes_on_their_way( + self, + instruments_logic: ReconstructionInstrumentsLogic, + mock_reconstruction_manager: MagicMock, + taking_turns: Reconstruction, + ) -> None: + """A redraw while a change waits draws the change, so the bars the reader moved stay where they left them.""" + mock_reconstruction_manager.current_features = _heard_features(taking_turns) + shared = Envelope[int](items=(5, 5)) + sole = Envelope[int](items=(3,)) + instruments_logic.handle_envelope_changed(SHARED_CHANNEL, FeatureKey.VOLUME, shared) + instruments_logic.handle_envelope_changed(SOLE_CHANNEL, FeatureKey.VOLUME, sole) + drawn: List[Optional[ChannelEnvelopesViewModel]] = [] + instruments_logic.on_feature_data_changed = drawn.append + + instruments_logic.update_display() + + envelopes = drawn[-1] + assert envelopes is not None + assert envelopes[SHARED_CHANNEL].volume == shared + assert envelopes[SOLE_CHANNEL].volume == sole diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_manager.py b/tests/unit/sampletones_application/logic/reconstruction/test_manager.py index bcf5ec885..f74453fd1 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_manager.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_manager.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Callable, List +from typing import Callable, Final, List from unittest.mock import MagicMock, patch import numpy as np @@ -21,6 +21,8 @@ from tests.suite.errors import DIRECTORY_READ_ERRORS from tests.suite.stems import RECORDED_SCALE, single_entry_stems_data +SAMPLE_VOICE_ID: Final[str] = "lead-id" + def _two_entry_stems_data() -> StemsData: return StemsData( @@ -112,7 +114,11 @@ def test_save_with_explicit_path_saves_file( reconstruction_factory: Callable[[], Reconstruction], tmp_path: Path, ) -> None: - reconstruction_manager.load_reconstruction_object(reconstruction_factory(), name="Sample") + reconstruction_manager.load_reconstruction_object( + reconstruction_factory(), + name="Sample", + voice_id=SAMPLE_VOICE_ID, + ) save_path = tmp_path / "saved.stn" assert reconstruction_manager.save_reconstruction(save_path) assert save_path.exists() @@ -122,7 +128,11 @@ def test_save_with_no_path_and_no_filepath_is_no_op( reconstruction_manager: ReconstructionManager, reconstruction_factory: Callable[[], Reconstruction], ) -> None: - reconstruction_manager.load_reconstruction_object(reconstruction_factory(), name="Sample") + reconstruction_manager.load_reconstruction_object( + reconstruction_factory(), + name="Sample", + voice_id=SAMPLE_VOICE_ID, + ) assert not reconstruction_manager.save_reconstruction() def test_save_when_nothing_loaded_is_no_op( @@ -145,7 +155,11 @@ def test_false_for_in_memory_object( reconstruction_manager: ReconstructionManager, reconstruction_factory: Callable[[], Reconstruction], ) -> None: - reconstruction_manager.load_reconstruction_object(reconstruction_factory(), name="Sample") + reconstruction_manager.load_reconstruction_object( + reconstruction_factory(), + name="Sample", + voice_id=SAMPLE_VOICE_ID, + ) assert not reconstruction_manager.is_file_backed def test_true_after_file_load( @@ -160,6 +174,67 @@ def test_true_after_file_load( assert reconstruction_manager.is_file_backed +class TestTheFileADocumentIsBackedBy: + """A conversion can write over the file the open document came from, so the manager tells its + own file however the path to it is spelled.""" + + @pytest.fixture(name="opened") + def opened_fixture( + self, + reconstruction_manager: ReconstructionManager, + reconstruction_factory: Callable[[], Reconstruction], + tmp_path: Path, + ) -> Path: + path = tmp_path / "song.stn" + reconstruction_factory().save(path) + reconstruction_manager.load_reconstruction(path) + return path + + def test_the_file_it_was_loaded_from( + self, + reconstruction_manager: ReconstructionManager, + opened: Path, + ) -> None: + assert reconstruction_manager.is_backed_by(opened) + + def test_the_same_file_spelled_another_way( + self, + reconstruction_manager: ReconstructionManager, + opened: Path, + ) -> None: + (opened.parent / "folder").mkdir() + + assert reconstruction_manager.is_backed_by(opened.parent / "folder" / ".." / opened.name) + + def test_another_file( + self, + reconstruction_manager: ReconstructionManager, + opened: Path, + ) -> None: + assert not reconstruction_manager.is_backed_by(opened.with_name("other.stn")) + + def test_nothing_open( + self, + reconstruction_manager: ReconstructionManager, + tmp_path: Path, + ) -> None: + assert not reconstruction_manager.is_backed_by(tmp_path / "song.stn") + + def test_a_document_held_in_memory( + self, + reconstruction_manager: ReconstructionManager, + reconstruction_factory: Callable[[], Reconstruction], + tmp_path: Path, + ) -> None: + reconstruction_manager.load_reconstruction_object( + reconstruction_factory(), + name="Sample", + voice_id=SAMPLE_VOICE_ID, + ) + + assert not reconstruction_manager.is_backed_by(tmp_path / "Sample.stn") + + class TestReconstructionManagerSaveReconstructionAs: def test_writes_the_file( self, @@ -167,7 +242,11 @@ def test_writes_the_file( reconstruction_factory: Callable[[], Reconstruction], tmp_path: Path, ) -> None: - reconstruction_manager.load_reconstruction_object(reconstruction_factory(), name="Sample") + reconstruction_manager.load_reconstruction_object( + reconstruction_factory(), + name="Sample", + voice_id=SAMPLE_VOICE_ID, + ) target = tmp_path / "detached.stn" reconstruction_manager.save_reconstruction_as(target) assert target.exists() @@ -178,7 +257,11 @@ def test_rebinds_to_a_file_backed_document( reconstruction_factory: Callable[[], Reconstruction], tmp_path: Path, ) -> None: - reconstruction_manager.load_reconstruction_object(reconstruction_factory(), name="Sample") + reconstruction_manager.load_reconstruction_object( + reconstruction_factory(), + name="Sample", + voice_id=SAMPLE_VOICE_ID, + ) target = tmp_path / "detached.stn" reconstruction_manager.save_reconstruction_as(target) assert reconstruction_manager.is_file_backed @@ -191,7 +274,7 @@ def test_severs_reconstruction_identity_from_the_original( tmp_path: Path, ) -> None: original = reconstruction_factory() - reconstruction_manager.load_reconstruction_object(original, name="Sample") + reconstruction_manager.load_reconstruction_object(original, name="Sample", voice_id=SAMPLE_VOICE_ID) reconstruction_manager.save_reconstruction_as(tmp_path / "detached.stn") assert reconstruction_manager.reconstruction is not original @@ -201,7 +284,11 @@ def test_marks_session_saved( reconstruction_factory: Callable[[], Reconstruction], tmp_path: Path, ) -> None: - reconstruction_manager.load_reconstruction_object(reconstruction_factory(), name="Sample") + reconstruction_manager.load_reconstruction_object( + reconstruction_factory(), + name="Sample", + voice_id=SAMPLE_VOICE_ID, + ) reconstruction_manager.mark_updated() reconstruction_manager.save_reconstruction_as(tmp_path / "detached.stn") assert not reconstruction_manager.session.unsaved_changes @@ -222,7 +309,11 @@ def test_load_object_marks_session_loaded( reconstruction_manager: ReconstructionManager, reconstruction_factory: Callable[[], Reconstruction], ) -> None: - reconstruction_manager.load_reconstruction_object(reconstruction_factory(), name="Sample") + reconstruction_manager.load_reconstruction_object( + reconstruction_factory(), + name="Sample", + voice_id=SAMPLE_VOICE_ID, + ) assert reconstruction_manager.session.is_loaded def test_load_object_sets_current_reconstruction( @@ -230,7 +321,11 @@ def test_load_object_sets_current_reconstruction( reconstruction_manager: ReconstructionManager, reconstruction_factory: Callable[[], Reconstruction], ) -> None: - reconstruction_manager.load_reconstruction_object(reconstruction_factory(), name="Sample") + reconstruction_manager.load_reconstruction_object( + reconstruction_factory(), + name="Sample", + voice_id=SAMPLE_VOICE_ID, + ) assert reconstruction_manager.current_reconstruction is not None def test_load_object_uses_supplied_name_for_session( @@ -238,7 +333,11 @@ def test_load_object_uses_supplied_name_for_session( reconstruction_manager: ReconstructionManager, reconstruction_factory: Callable[[], Reconstruction], ) -> None: - reconstruction_manager.load_reconstruction_object(reconstruction_factory(), name="Kick drum") + reconstruction_manager.load_reconstruction_object( + reconstruction_factory(), + name="Kick drum", + voice_id=SAMPLE_VOICE_ID, + ) assert reconstruction_manager.session.name == "Kick drum" def test_load_object_sets_reconstruction_by_identity( @@ -247,7 +346,7 @@ def test_load_object_sets_reconstruction_by_identity( reconstruction_factory: Callable[[], Reconstruction], ) -> None: reconstruction = reconstruction_factory() - reconstruction_manager.load_reconstruction_object(reconstruction, name="Sample") + reconstruction_manager.load_reconstruction_object(reconstruction, name="Sample", voice_id=SAMPLE_VOICE_ID) assert reconstruction_manager.reconstruction is reconstruction def test_load_object_fires_on_reconstruction_loaded_callback( @@ -257,7 +356,11 @@ def test_load_object_fires_on_reconstruction_loaded_callback( ) -> None: callback = MagicMock() reconstruction_manager.on_reconstruction_loaded = callback - reconstruction_manager.load_reconstruction_object(reconstruction_factory(), name="Sample") + reconstruction_manager.load_reconstruction_object( + reconstruction_factory(), + name="Sample", + voice_id=SAMPLE_VOICE_ID, + ) callback.assert_called_once() @@ -292,7 +395,11 @@ def test_close_resets_current_reconstruction_to_none( reconstruction_manager: ReconstructionManager, reconstruction_factory: Callable[[], Reconstruction], ) -> None: - reconstruction_manager.load_reconstruction_object(reconstruction_factory(), name="Sample") + reconstruction_manager.load_reconstruction_object( + reconstruction_factory(), + name="Sample", + voice_id=SAMPLE_VOICE_ID, + ) reconstruction_manager.close_reconstruction() assert reconstruction_manager.current_reconstruction is None @@ -303,7 +410,11 @@ def test_close_fires_on_reconstruction_closed_callback( ) -> None: callback = MagicMock() reconstruction_manager.on_reconstruction_closed = callback - reconstruction_manager.load_reconstruction_object(reconstruction_factory(), name="Sample") + reconstruction_manager.load_reconstruction_object( + reconstruction_factory(), + name="Sample", + voice_id=SAMPLE_VOICE_ID, + ) reconstruction_manager.close_reconstruction() callback.assert_called_once() @@ -312,7 +423,11 @@ def test_close_marks_session_closed( reconstruction_manager: ReconstructionManager, reconstruction_factory: Callable[[], Reconstruction], ) -> None: - reconstruction_manager.load_reconstruction_object(reconstruction_factory(), name="Sample") + reconstruction_manager.load_reconstruction_object( + reconstruction_factory(), + name="Sample", + voice_id=SAMPLE_VOICE_ID, + ) reconstruction_manager.close_reconstruction() assert not reconstruction_manager.session.is_loaded @@ -321,7 +436,11 @@ def test_close_resets_source_paths_to_empty( reconstruction_manager: ReconstructionManager, reconstruction_factory: Callable[[], Reconstruction], ) -> None: - reconstruction_manager.load_reconstruction_object(reconstruction_factory(), name="Sample") + reconstruction_manager.load_reconstruction_object( + reconstruction_factory(), + name="Sample", + voice_id=SAMPLE_VOICE_ID, + ) reconstruction_manager.close_reconstruction() assert reconstruction_manager.source_paths == () @@ -333,7 +452,7 @@ def test_reconstruction_property_returns_the_reconstruction_object( reconstruction_factory: Callable[[], Reconstruction], ) -> None: reconstruction = reconstruction_factory() - reconstruction_manager.load_reconstruction_object(reconstruction, name="Sample") + reconstruction_manager.load_reconstruction_object(reconstruction, name="Sample", voice_id=SAMPLE_VOICE_ID) assert reconstruction_manager.reconstruction is reconstruction def test_filepath_property_is_none_for_in_memory_reconstruction( @@ -341,7 +460,11 @@ def test_filepath_property_is_none_for_in_memory_reconstruction( reconstruction_manager: ReconstructionManager, reconstruction_factory: Callable[[], Reconstruction], ) -> None: - reconstruction_manager.load_reconstruction_object(reconstruction_factory(), name="Sample") + reconstruction_manager.load_reconstruction_object( + reconstruction_factory(), + name="Sample", + voice_id=SAMPLE_VOICE_ID, + ) assert reconstruction_manager.filepath is None def test_source_paths_return_reconstruction_source_paths( @@ -350,7 +473,7 @@ def test_source_paths_return_reconstruction_source_paths( reconstruction_factory: Callable[[], Reconstruction], ) -> None: reconstruction = reconstruction_factory() - reconstruction_manager.load_reconstruction_object(reconstruction, name="Sample") + reconstruction_manager.load_reconstruction_object(reconstruction, name="Sample", voice_id=SAMPLE_VOICE_ID) assert reconstruction_manager.source_paths == reconstruction.audio_filepath def test_current_features_is_populated_after_load( @@ -358,7 +481,11 @@ def test_current_features_is_populated_after_load( reconstruction_manager: ReconstructionManager, reconstruction_factory: Callable[[], Reconstruction], ) -> None: - reconstruction_manager.load_reconstruction_object(reconstruction_factory(), name="Sample") + reconstruction_manager.load_reconstruction_object( + reconstruction_factory(), + name="Sample", + voice_id=SAMPLE_VOICE_ID, + ) assert reconstruction_manager.current_features is not None @@ -410,7 +537,7 @@ def test_the_recording_leaves_what_the_reader_hears( audio_filepath=(tmp_path / "kick.wav", tmp_path / "snare.wav"), stems_data=_two_entry_stems_data(), ) - reconstruction_manager.load_reconstruction_object(reconstruction, name="Sample") + reconstruction_manager.load_reconstruction_object(reconstruction, name="Sample", voice_id=SAMPLE_VOICE_ID) reconstruction_manager.apply_edited(self._edited(reconstruction)) @@ -421,13 +548,119 @@ def test_the_recording_leaves_what_the_reader_hears( assert features[ChannelName.PULSE1].volume.items[-1] == 0 +class TestTheVoiceTheDocumentIs: + """The open document remembers the project voice it is, which is how the tab follows it.""" + + def test_a_project_sample_names_its_voice( + self, + reconstruction_manager: ReconstructionManager, + reconstruction_factory: Callable[[], Reconstruction], + ) -> None: + reconstruction_manager.load_reconstruction_object( + reconstruction_factory(), + name="Sample", + voice_id=SAMPLE_VOICE_ID, + ) + + assert reconstruction_manager.voice_id == SAMPLE_VOICE_ID + assert reconstruction_manager.is_project_sample + + def test_a_file_names_no_voice( + self, + reconstruction_manager: ReconstructionManager, + reconstruction_factory: Callable[[], Reconstruction], + tmp_path: Path, + ) -> None: + path = tmp_path / "song.stn" + reconstruction_factory().save(path) + reconstruction_manager.load_reconstruction_object( + reconstruction_factory(), + name="Sample", + voice_id=SAMPLE_VOICE_ID, + ) + + reconstruction_manager.load_reconstruction(path) + + assert reconstruction_manager.voice_id is None + assert not reconstruction_manager.is_project_sample + + def test_an_edit_keeps_the_voice( + self, + reconstruction_manager: ReconstructionManager, + reconstruction_factory: Callable[[], Reconstruction], + ) -> None: + reconstruction_manager.load_reconstruction_object( + reconstruction_factory(), + name="Sample", + voice_id=SAMPLE_VOICE_ID, + ) + + reconstruction_manager.apply_edited(reconstruction_factory()) + + assert reconstruction_manager.voice_id == SAMPLE_VOICE_ID + + def test_save_as_lets_the_voice_go( + self, + reconstruction_manager: ReconstructionManager, + reconstruction_factory: Callable[[], Reconstruction], + tmp_path: Path, + ) -> None: + """The saved copy is a standalone document, so its edits reach only its file.""" + reconstruction_manager.load_reconstruction_object( + reconstruction_factory(), + name="Sample", + voice_id=SAMPLE_VOICE_ID, + ) + + reconstruction_manager.save_reconstruction_as(tmp_path / "detached.stn") + + assert reconstruction_manager.voice_id is None + + def test_a_failed_save_as_keeps_the_voice( + self, + reconstruction_manager: ReconstructionManager, + reconstruction_factory: Callable[[], Reconstruction], + tmp_path: Path, + ) -> None: + reconstruction_manager.load_reconstruction_object( + reconstruction_factory(), + name="Sample", + voice_id=SAMPLE_VOICE_ID, + ) + + with pytest.raises(OSError): + reconstruction_manager.save_reconstruction_as(tmp_path / "missing" / "detached.stn") + + assert reconstruction_manager.voice_id == SAMPLE_VOICE_ID + + def test_closing_lets_the_voice_go( + self, + reconstruction_manager: ReconstructionManager, + reconstruction_factory: Callable[[], Reconstruction], + ) -> None: + reconstruction_manager.load_reconstruction_object( + reconstruction_factory(), + name="Sample", + voice_id=SAMPLE_VOICE_ID, + ) + + reconstruction_manager.close_reconstruction() + + assert reconstruction_manager.voice_id is None + assert not reconstruction_manager.is_project_sample + + class TestReconstructionManagerMarkUpdated: def test_mark_updated_sets_unsaved_changes( self, reconstruction_manager: ReconstructionManager, reconstruction_factory: Callable[[], Reconstruction], ) -> None: - reconstruction_manager.load_reconstruction_object(reconstruction_factory(), name="Sample") + reconstruction_manager.load_reconstruction_object( + reconstruction_factory(), + name="Sample", + voice_id=SAMPLE_VOICE_ID, + ) reconstruction_manager.mark_updated() assert reconstruction_manager.session.unsaved_changes @@ -458,7 +691,7 @@ def test_locate_audio_raises_file_not_found_when_audio_missing( {ChannelName.PULSE1: [PulseInstruction(on=True, pitch=60, volume=8, duty_cycle=0)]}, ), ) - reconstruction_manager.load_reconstruction_object(reconstruction, name="Sample") + reconstruction_manager.load_reconstruction_object(reconstruction, name="Sample", voice_id=SAMPLE_VOICE_ID) with pytest.raises(FileNotFoundError): reconstruction_manager.locate_original_audio() @@ -484,7 +717,7 @@ def test_locate_audio_opens_the_recorded_paths( audio_filepath=(first, second), stems_data=_two_entry_stems_data(), ) - reconstruction_manager.load_reconstruction_object(reconstruction, name="Sample") + reconstruction_manager.load_reconstruction_object(reconstruction, name="Sample", voice_id=SAMPLE_VOICE_ID) with patch("sampletones_application.logic.reconstruction.manager.open_paths_in_explorer") as open_paths: reconstruction_manager.locate_original_audio() @@ -506,7 +739,7 @@ def test_locate_audio_reports_the_first_missing_path( audio_filepath=(present, missing), stems_data=_two_entry_stems_data(), ) - reconstruction_manager.load_reconstruction_object(reconstruction, name="Sample") + reconstruction_manager.load_reconstruction_object(reconstruction, name="Sample", voice_id=SAMPLE_VOICE_ID) with pytest.raises(FileNotFoundError) as raised: reconstruction_manager.locate_original_audio() diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py index f15690060..cdc69a5bb 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py @@ -496,57 +496,32 @@ def test_a_closed_tab_states_no_rate( assert received[0].nes_frequency is None -class TestReconstructionPanelLogicRetuning: - """A reader changing the rate re-times the open document and every reading of it.""" +class TestAReTimedDocumentIsShown: + """A document re-timed to another rate is shown at it, every reading of it re-answered. + + The rate reaches the document as a step of its edits, and the tab then asks for every reading + again with the waveform re-fitted, since the audio spans another length. + """ @staticmethod - def _rebinding(manager: MagicMock) -> None: - """Lets the stand-in manager rebind the open document the way the real one does.""" - manager.apply_edited.side_effect = lambda reconstruction: setattr( - manager, - "current_reconstruction", - manager.current_reconstruction.with_reconstruction(reconstruction), + def _retime(manager: MagicMock, loaded_data: ReconstructionData) -> None: + """Opens the document, then lets the stand-in manager adopt it re-timed, as a landed retune leaves it.""" + _open(manager, loaded_data) + manager.current_reconstruction = loaded_data.with_reconstruction( + loaded_data.reconstruction.with_nes_frequency(PAL_FREQUENCY) ) - def test_the_document_is_rebound_to_a_reconstruction_at_the_new_rate( - self, - panel_logic: ReconstructionPanelLogic, - mock_reconstruction_manager: MagicMock, - loaded_data: ReconstructionData, - ) -> None: - self._rebinding(mock_reconstruction_manager) - _open(mock_reconstruction_manager, loaded_data) - - panel_logic.set_nes_frequency(PAL_FREQUENCY) - - retuned = mock_reconstruction_manager.apply_edited.call_args.args[0] - assert retuned.config.nes_frequency == PAL_FREQUENCY - - def test_the_change_stands_as_an_unsaved_edit( - self, - panel_logic: ReconstructionPanelLogic, - mock_reconstruction_manager: MagicMock, - loaded_data: ReconstructionData, - ) -> None: - self._rebinding(mock_reconstruction_manager) - _open(mock_reconstruction_manager, loaded_data) - - panel_logic.set_nes_frequency(PAL_FREQUENCY) - - mock_reconstruction_manager.mark_updated.assert_called_once_with() - def test_the_view_states_the_new_rate( self, panel_logic: ReconstructionPanelLogic, mock_reconstruction_manager: MagicMock, loaded_data: ReconstructionData, ) -> None: - self._rebinding(mock_reconstruction_manager) - _open(mock_reconstruction_manager, loaded_data) + self._retime(mock_reconstruction_manager, loaded_data) received: List[ReconstructionViewModel] = [] panel_logic.on_view_changed = received.append - panel_logic.set_nes_frequency(PAL_FREQUENCY) + panel_logic.update_reconstruction(refit_waveform=True) assert received[-1].nes_frequency == PAL_FREQUENCY @@ -556,14 +531,13 @@ def test_the_waveform_and_the_audio_follow_the_new_rate( mock_reconstruction_manager: MagicMock, loaded_data: ReconstructionData, ) -> None: - self._rebinding(mock_reconstruction_manager) - _open(mock_reconstruction_manager, loaded_data) + self._retime(mock_reconstruction_manager, loaded_data) waveforms: List[WaveformData] = [] audio: List[Optional[AudioData]] = [] panel_logic.on_waveform_update_changed = lambda waveform, _channels, **_kwargs: waveforms.append(waveform) panel_logic.on_audio_data_changed = audio.append - panel_logic.set_nes_frequency(PAL_FREQUENCY) + panel_logic.update_reconstruction(refit_waveform=True) assert len(waveforms) == 1 assert len(audio) == 1 @@ -575,37 +549,14 @@ def test_the_waveform_view_re_fits_to_the_retuned_length( loaded_data: ReconstructionData, ) -> None: """A retune moves the audio's own length, so the old view no longer answers to it.""" - self._rebinding(mock_reconstruction_manager) - _open(mock_reconstruction_manager, loaded_data) + self._retime(mock_reconstruction_manager, loaded_data) refits: List[bool] = [] panel_logic.on_waveform_update_changed = lambda _waveform, _channels, *, refit: refits.append(refit) - panel_logic.set_nes_frequency(PAL_FREQUENCY) + panel_logic.update_reconstruction(refit_waveform=True) assert refits == [True] - def test_the_rate_the_document_already_runs_at_changes_nothing( - self, - panel_logic: ReconstructionPanelLogic, - mock_reconstruction_manager: MagicMock, - loaded_data: ReconstructionData, - ) -> None: - _open(mock_reconstruction_manager, loaded_data) - - panel_logic.set_nes_frequency(loaded_data.config.nes_frequency) - - mock_reconstruction_manager.apply_edited.assert_not_called() - mock_reconstruction_manager.mark_updated.assert_not_called() - - def test_a_tab_holding_no_document_changes_nothing( - self, - panel_logic: ReconstructionPanelLogic, - mock_reconstruction_manager: MagicMock, - ) -> None: - panel_logic.set_nes_frequency(PAL_FREQUENCY) - - mock_reconstruction_manager.apply_edited.assert_not_called() - class TestReconstructionPanelLogicClose: def test_close_fires_on_waveform_cleared( 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 64859d917..e5c4c3076 100644 --- a/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py +++ b/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py @@ -866,9 +866,9 @@ def test_frequency_change_between_rows_takes_effect_without_restart(self) -> Non class TestChannelHeldValues: """A dimension an instrument leaves to the channel sounds at the value the channel holds. - The channel carries that value from the start of a song, taking up a new one wherever an - instrument writes it, so an instrument with an empty volume envelope plays at whatever the - one before it left behind. + Every note starts that value where a song starts it, and the channel takes up a new one + wherever the instrument writes it, so an instrument with an empty volume envelope plays at full + volume whatever the one before it wrote. """ @staticmethod @@ -904,7 +904,8 @@ def test_the_channel_takes_up_the_level_its_instrument_writes(self) -> None: assert _performance(context).feature_values[FeatureKey.VOLUME] == QUIET_VOLUME - def test_a_sample_holding_its_level_sounds_at_the_channels(self) -> None: + def test_a_sample_holding_its_level_starts_at_full_volume_after_a_quieter_one(self) -> None: + """A level one note wrote ends with it, so the next note sounds as a song would start it.""" context = _make_context() self._place( context, @@ -922,11 +923,18 @@ def test_a_sample_holding_its_level_sounds_at_the_channels(self) -> None: row_index=1, name="holds", ) + loud = _make_context() + self._place( + loud, + make_pulse_reconstruction(volume=MAX_VOLUME, count=SUSTAINED_FRAMES), + row_index=0, + name="writes", + ) - written = _render(context) + _render(context) held = _render(context) - assert self._peak(held) == pytest.approx(self._peak(written)) + assert self._peak(held) == pytest.approx(self._peak(_render(loud))) def test_a_song_starts_a_held_level_at_full_volume(self) -> None: holding = _make_context() diff --git a/tests/unit/sampletones_application/logic/shared/test_tree.py b/tests/unit/sampletones_application/logic/shared/test_tree.py index 2272df811..a085d6cdc 100644 --- a/tests/unit/sampletones_application/logic/shared/test_tree.py +++ b/tests/unit/sampletones_application/logic/shared/test_tree.py @@ -33,7 +33,6 @@ def _tree( scheduling = SchedulingBehavior( delays=SchedulingDelays( schedule=0, - reconstruction_update=0, cancel=0, ), priorities=SchedulingPriorities( diff --git a/tests/unit/sampletones_application/services/export/test_service.py b/tests/unit/sampletones_application/services/export/test_service.py index 5efe54824..40eeb36c1 100644 --- a/tests/unit/sampletones_application/services/export/test_service.py +++ b/tests/unit/sampletones_application/services/export/test_service.py @@ -16,7 +16,7 @@ ) from sampletones_core.constants.enums import ChannelName from sampletones_core.exporters import Features -from sampletones_core.exporters.skipped import NO_SKIPPED_ROWS, SkippedRow +from sampletones_core.exporters.skipped import NO_SKIPPED_ROWS, SkippedRow, SkipReason from sampletones_core.exporters.truncation import EnvelopeTruncation from sampletones_core.exports.artifact import ExportArtifact from sampletones_core.exports.format import ExportFormat @@ -491,6 +491,7 @@ class TestExportSkippedRowsReporting: channel=ChannelName.PULSE1, order_position=3, row_index=26, + reason=SkipReason.NO_INSTRUMENT, ), ) diff --git a/tests/unit/sampletones_application/services/retune/test_retune.py b/tests/unit/sampletones_application/services/retune/test_retune.py index e30d59b63..19c5bf837 100644 --- a/tests/unit/sampletones_application/services/retune/test_retune.py +++ b/tests/unit/sampletones_application/services/retune/test_retune.py @@ -46,15 +46,16 @@ def test_emits_service_error_when_a_retune_raises(self) -> None: assert len(results) == 1 assert isinstance(results[0], ServiceError) - def test_result_carries_the_voice_id_and_retuned_reconstruction(self) -> None: + def test_result_carries_the_voice_id_the_retuned_reconstruction_and_its_source(self) -> None: service = SampleRetuneService() results: List[Any] = [] service.subscribe(results.append) retuned = MagicMock() + source = _reconstruction(retuned) - service._run([("lead", _reconstruction(retuned))], 60) + service._run([("lead", source)], 60) - assert results[0].value == RetunedSample(voice_id="lead", reconstruction=retuned) + assert results[0].value == RetunedSample(voice_id="lead", reconstruction=retuned, source=source) class TestSampleRetuneServiceStart: diff --git a/tests/unit/sampletones_application/services/test_regeneration.py b/tests/unit/sampletones_application/services/test_regeneration.py index 767c46152..d45b03b49 100644 --- a/tests/unit/sampletones_application/services/test_regeneration.py +++ b/tests/unit/sampletones_application/services/test_regeneration.py @@ -1,4 +1,3 @@ -import threading from types import SimpleNamespace from typing import Any, Callable, Dict, Final, FrozenSet, Iterator, List, Tuple, TypeAlias, cast from unittest.mock import MagicMock, patch @@ -8,11 +7,10 @@ from sampletones_application.services.regeneration.service import RegenerationService from sampletones_application.services.result import ( - ServiceCanceled, ServiceError, ServiceSuccess, ) -from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.constants.enums import ChannelName from sampletones_core.exporters import Features from sampletones_core.features import CHANNEL_GENERATOR_KIND, supported_features from sampletones_core.features.envelope import Envelope @@ -73,100 +71,46 @@ def reconstruction() -> MockReconstruction: class TestRegenerationServiceStart: - def test_start_when_not_canceled_returns_true( - self, synthesis_mocks: SynthesisMocks, reconstruction: MockReconstruction + def test_start_runs_the_rebuild_on_the_worker( + self, + synthesis_mocks: SynthesisMocks, + reconstruction: MockReconstruction, + features: Features, ) -> None: - service = RegenerationService() - result = service.start( - reconstruction, - synthesis_mocks.channel_name, - FeatureKey.VOLUME, - cast(Features, {}), - EVERY_STEM, - ) - assert result is True - - def test_start_when_canceled_returns_false(self) -> None: - service = RegenerationService() - service.cancel() - - result = service.start(MagicMock(), MagicMock(), FeatureKey.VOLUME, cast(Features, {}), EVERY_STEM) - - assert result is False - - def test_start_when_canceled_does_not_emit(self) -> None: + """The worker runs the job it is handed, and its result reaches the subscriber.""" service = RegenerationService() results: List[Any] = [] service.subscribe(results.append) - service.cancel() service.start( - MagicMock(), - MagicMock(), - MagicMock(), - cast(Features, {}), + reconstruction, + synthesis_mocks.channel_name, + features, EVERY_STEM, ) - assert results == [] + assert len(results) == 1 + assert isinstance(results[0], ServiceSuccess) - def test_start_reports_a_submit_failure(self) -> None: - """``start`` propagates the executor's accepted/rejected verdict. + def test_a_job_started_as_the_last_one_winds_down_still_runs( + self, + synthesis_mocks: SynthesisMocks, + reconstruction: MockReconstruction, + features: Features, + ) -> None: + """One job runs at a time, and a job started while the worker winds down runs once it has. - The coalescing worker owns the launch; ``start`` merely forwards whether the - submission was accepted, so a caller can gate on it. + The rewrites start the next rebuild as the previous result arrives, so every start is + answered and the line moves on. """ service = RegenerationService() - with patch.object(service._executor, "submit", return_value=False): - result = service.start( - MagicMock(), - MagicMock(), - MagicMock(), - cast(Features, {}), - EVERY_STEM, - ) - - assert result is False - - def test_cancel_sets_canceled_flag(self) -> None: - service = RegenerationService() - assert not service._canceled - - service.cancel() - - assert service._canceled - - -class TestRegenerationServiceIsRunning: - def test_is_running_delegates_to_the_executor(self) -> None: - service = RegenerationService() - service._executor = MagicMock() + with patch.object(service._executor, "execute") as execute: + service.start(reconstruction, synthesis_mocks.channel_name, features, EVERY_STEM) - service._executor.is_running = True - assert service.is_running() is True - - service._executor.is_running = False - assert service.is_running() is False + assert execute.call_args.kwargs == {"wait": True} class TestRegenerationServiceRun: - def test_run_when_canceled_emits_service_canceled(self) -> None: - service = RegenerationService() - results: List[Any] = [] - service.subscribe(results.append) - service._canceled = True - - service._run( - MagicMock(), - MagicMock(), - MagicMock(), - cast(Features, {}), - EVERY_STEM, - ) - - assert len(results) == 1 - assert isinstance(results[0], ServiceCanceled) - def test_run_success_emits_service_success( self, synthesis_mocks: SynthesisMocks, @@ -180,7 +124,6 @@ def test_run_success_emits_service_success( service._run( reconstruction, synthesis_mocks.channel_name, - FeatureKey.VOLUME, features, EVERY_STEM, ) @@ -190,8 +133,6 @@ def test_run_success_emits_service_success( outcome = results[0].value assert outcome.reconstruction is reconstruction.model_copy.return_value assert outcome.reconstruction is not reconstruction - assert outcome.channel_name is synthesis_mocks.channel_name - assert outcome.feature_key is FeatureKey.VOLUME def test_run_regenerates_from_the_envelopes_it_is_handed( self, @@ -202,7 +143,7 @@ def test_run_regenerates_from_the_envelopes_it_is_handed( """The caller writes the edit into the envelopes, so the service renders what it is given.""" service = RegenerationService() - service._run(reconstruction, synthesis_mocks.channel_name, FeatureKey.VOLUME, features, EVERY_STEM) + service._run(reconstruction, synthesis_mocks.channel_name, features, EVERY_STEM) _, _, initial_pitch, held = reconstruction.model_copy.return_value.update_channel_data.call_args.args assert initial_pitch == features.initial_pitch @@ -219,7 +160,6 @@ def test_run_updates_reconstruction_copy( service._run( reconstruction, synthesis_mocks.channel_name, - FeatureKey.VOLUME, features, EVERY_STEM, ) @@ -246,7 +186,6 @@ def test_run_carries_the_reference_pitch_through_an_arpeggio_edit( service._run( reconstruction, synthesis_mocks.channel_name, - FeatureKey.ARPEGGIO, features, EVERY_STEM, ) @@ -264,7 +203,7 @@ def test_run_carries_a_moved_reference_pitch( moved = features.model_copy(update={"initial_pitch": REFERENCE_PITCH + 12}) service = RegenerationService() - service._run(reconstruction, synthesis_mocks.channel_name, FeatureKey.INITIAL_PITCH, moved, EVERY_STEM) + service._run(reconstruction, synthesis_mocks.channel_name, moved, EVERY_STEM) _, _, initial_pitch, _ = reconstruction.model_copy.return_value.update_channel_data.call_args.args assert initial_pitch == REFERENCE_PITCH + 12 @@ -283,7 +222,6 @@ def test_run_hands_on_every_instruction_the_envelopes_describe( service._run( reconstruction, synthesis_mocks.channel_name, - FeatureKey.VOLUME, features, EVERY_STEM, ) @@ -310,7 +248,6 @@ def test_run_exception_emits_service_error( service._run( reconstruction, ChannelName.PULSE1, - FeatureKey.VOLUME, cast(Features, {}), EVERY_STEM, ) @@ -335,7 +272,6 @@ def test_run_exception_does_not_update_reconstruction( service._run( reconstruction, ChannelName.PULSE1, - FeatureKey.VOLUME, cast(Features, {}), EVERY_STEM, ) @@ -363,7 +299,6 @@ def _regenerated(reconstruction: Reconstruction) -> Reconstruction: service._run( reconstruction, ChannelName.PULSE1, - FeatureKey.VOLUME, features, EVERY_STEM, ) @@ -416,87 +351,3 @@ def test_the_reconstruction_the_edit_was_made_from_keeps_playing( self._regenerated(reconstruction) assert reconstruction.playing_channels == (ChannelName.PULSE1,) - - -class TestRegenerationServiceCancellationConstraints: - """Tests that document the non-preemptive cancellation behavior. - - cancel() only prevents new tasks from starting. It does NOT interrupt - synthesis that is already in progress. - """ - - def test_cancel_while_running_does_not_interrupt_synthesis( - self, - synthesis_mocks: SynthesisMocks, - features: Features, - ) -> None: - service = RegenerationService() - results: List[Any] = [] - done = threading.Event() - - def on_result(result: Any) -> None: - results.append(result) - done.set() - - service.subscribe(on_result) - - task_started = threading.Event() - task_unblock = threading.Event() - - def blocking_from_features(edited_features: Any) -> List[MagicMock]: - task_started.set() - task_unblock.wait(timeout=2.0) - return [synthesis_mocks.instruction] - - synthesis_mocks.exporter.from_features.side_effect = blocking_from_features - reconstruction = MagicMock() - reconstruction.config = MagicMock() - - thread = threading.Thread( - target=lambda: service._run( - reconstruction, - synthesis_mocks.channel_name, - FeatureKey.VOLUME, - features, - EVERY_STEM, - ), - ) - thread.start() - task_started.wait(timeout=2.0) - - service.cancel() - task_unblock.set() - - done.wait(timeout=2.0) - thread.join(timeout=2.0) - - assert len(results) == 1 - assert isinstance(results[0], ServiceSuccess) - - def test_cancel_after_completion_prevents_new_tasks( - self, - synthesis_mocks: SynthesisMocks, - reconstruction: MockReconstruction, - ) -> None: - service = RegenerationService() - results: List[Any] = [] - service.subscribe(results.append) - - service.start( - reconstruction, - synthesis_mocks.channel_name, - FeatureKey.VOLUME, - cast(Features, {}), - EVERY_STEM, - ) - - service.cancel() - second_result = service.start( - reconstruction, - synthesis_mocks.channel_name, - FeatureKey.VOLUME, - cast(Features, {}), - EVERY_STEM, - ) - - assert second_result is False diff --git a/tests/unit/sampletones_application/test_application_exit.py b/tests/unit/sampletones_application/test_application_exit.py new file mode 100644 index 000000000..eb32884e0 --- /dev/null +++ b/tests/unit/sampletones_application/test_application_exit.py @@ -0,0 +1,199 @@ +from typing import Final, List, Optional, Tuple +from unittest.mock import MagicMock + +import pytest + +from sampletones_application.application import Application +from sampletones_shared.types.callback import VoidCallback + +PROJECT: Final[str] = "project" +RECONSTRUCTION: Final[str] = "reconstruction" +CONVERSION: Final[str] = "conversion" +LIBRARY: Final[str] = "library" +OWNERS: Final[Tuple[str, ...]] = (PROJECT, RECONSTRUCTION, CONVERSION, LIBRARY) + + +class Owner: + """One owner of something the exit asks about, answering the way a real prompt would.""" + + def __init__(self, name: str, asked: List[str]) -> None: + self.name = name + self.unfinished = False + self.editing = False + self._asked = asked + self._proceed: Optional[VoidCallback] = None + self._after_edits: Optional[VoidCallback] = None + + def after_edits(self, gesture: VoidCallback) -> None: + """Holds the gesture while an edit is on its way, the way the reconstruction's rewrites do.""" + if not self.editing: + gesture() + return + + self._after_edits = gesture + + def land(self) -> None: + """The edit on its way landing, which lets a gesture waiting on it run.""" + assert self._after_edits is not None + gesture, self._after_edits = self._after_edits, None + self.editing = False + gesture() + + def guard_exit(self, proceed: VoidCallback) -> None: + if not self.unfinished: + proceed() + return + + self._asked.append(self.name) + self._proceed = proceed + + @property + def is_asking(self) -> bool: + return self._proceed is not None + + def go_on(self) -> None: + """The reader answering the question with Save, Exit or Discard.""" + assert self._proceed is not None + proceed, self._proceed = self._proceed, None + proceed() + + def cancel(self) -> None: + """The reader answering the question with Cancel, which ends the exit.""" + self._proceed = None + + +class Exiting: + """An application whose owners ask about what they hold, and whose exit is only recorded.""" + + def __init__(self) -> None: + self.asked: List[str] = [] + self.owners = {name: Owner(name, self.asked) for name in OWNERS} + self.application = Application.__new__(Application) + self.application._project_coordinator = self.owners[PROJECT] + self.application._reconstruction_coordinator = self.owners[RECONSTRUCTION] + self.application._main_tab = self.owners[CONVERSION] + self.application._instructions_tab = self.owners[LIBRARY] + self.exit = MagicMock() + self.application._exit_application = self.exit + + def unfinished(self, *names: str) -> None: + for name in names: + self.owners[name].unfinished = True + + def close(self) -> None: + self.application._on_close() + + +@pytest.fixture(name="exiting") +def exiting_fixture() -> Exiting: + return Exiting() + + +class TestExitingWithNothingUnfinished: + def test_the_application_exits_at_once(self, exiting: Exiting) -> None: + exiting.close() + + exiting.exit.assert_called_once_with() + assert exiting.asked == [] + + +class TestExitingWithEverythingUnfinished: + """Each owner asks in turn, and the application exits once the last one lets it go.""" + + @pytest.fixture(name="closing") + def closing_fixture(self, exiting: Exiting) -> Exiting: + exiting.unfinished(*OWNERS) + exiting.close() + return exiting + + def test_the_project_asks_first(self, closing: Exiting) -> None: + assert closing.asked == [PROJECT] + closing.exit.assert_not_called() + + def test_its_answer_leads_to_the_reconstruction_question(self, closing: Exiting) -> None: + closing.owners[PROJECT].go_on() + + assert closing.asked == [PROJECT, RECONSTRUCTION] + closing.exit.assert_not_called() + + def test_every_owner_is_asked_in_turn(self, closing: Exiting) -> None: + for name in OWNERS: + closing.owners[name].go_on() + + assert closing.asked == list(OWNERS) + closing.exit.assert_called_once_with() + + def test_canceling_any_question_keeps_the_application_open(self, closing: Exiting) -> None: + closing.owners[PROJECT].go_on() + closing.owners[RECONSTRUCTION].cancel() + + assert closing.asked == [PROJECT, RECONSTRUCTION] + closing.exit.assert_not_called() + + +class TestAStateSettledWhileAQuestionStood: + """An owner reads what it holds when the exit reaches it, so a job ending while the project + question stands is asked about no more.""" + + def test_a_finished_conversion_asks_nothing(self, exiting: Exiting) -> None: + exiting.unfinished(PROJECT, CONVERSION) + exiting.close() + + exiting.owners[CONVERSION].unfinished = False + exiting.owners[PROJECT].go_on() + + assert exiting.asked == [PROJECT] + exiting.exit.assert_called_once_with() + + +class TestEachOwnerAlone: + """Whichever owner holds something unfinished, its answer is what reaches the exit.""" + + @pytest.mark.parametrize("name", OWNERS) + def test_going_on_exits(self, exiting: Exiting, name: str) -> None: + exiting.unfinished(name) + exiting.close() + + exiting.owners[name].go_on() + + assert exiting.asked == [name] + exiting.exit.assert_called_once_with() + + @pytest.mark.parametrize("name", OWNERS) + def test_canceling_stays(self, exiting: Exiting, name: str) -> None: + exiting.unfinished(name) + exiting.close() + + exiting.owners[name].cancel() + + exiting.exit.assert_not_called() + + +class TestExitingWhileAnEditIsOnItsWay: + """The exit waits for the edits of the open reconstruction first, so every question asks about what the reader drew.""" + + def test_nothing_is_asked_before_the_edit_lands(self, exiting: Exiting) -> None: + exiting.owners[RECONSTRUCTION].editing = True + exiting.unfinished(PROJECT) + + exiting.close() + + assert exiting.asked == [] + exiting.exit.assert_not_called() + + def test_the_questions_follow_once_it_lands(self, exiting: Exiting) -> None: + exiting.owners[RECONSTRUCTION].editing = True + exiting.unfinished(PROJECT) + exiting.close() + + exiting.owners[RECONSTRUCTION].land() + + assert exiting.asked == [PROJECT] + + def test_the_application_exits_once_it_lands_with_nothing_unfinished(self, exiting: Exiting) -> None: + exiting.owners[RECONSTRUCTION].editing = True + exiting.close() + + exiting.owners[RECONSTRUCTION].land() + + exiting.exit.assert_called_once_with() diff --git a/tests/unit/sampletones_application/test_application_gestures.py b/tests/unit/sampletones_application/test_application_gestures.py new file mode 100644 index 000000000..0077e1f27 --- /dev/null +++ b/tests/unit/sampletones_application/test_application_gestures.py @@ -0,0 +1,172 @@ +from dataclasses import dataclass +from typing import Callable, Final, Tuple +from unittest.mock import MagicMock + +import pytest + +from sampletones_application.application import Application +from sampletones_application.coordinators.reconstruction import ReconstructionCoordinator +from sampletones_application.shell import ShortcutBindings +from sampletones_core.exports.format import ExportFormat +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase +from tests.suite.gates import HeldGate, held_gate + +__all__ = ["held_gate"] + +COLLABORATORS: Final[Tuple[str, ...]] = ( + "_project_coordinator", + "_render_coordinator", + "_sequencer_tab", + "_config_coordinator", + "_display_coordinator", + "_keybindings_coordinator", + "_shell", +) +OWN_GESTURES: Final[Tuple[str, ...]] = ( + "_add_current_reconstruction_to_sequencer", + "_export_reconstruction_wav_dialog", + "_export_reconstruction_instruments_dialog", +) + + +@pytest.fixture +def app(held_gate: HeldGate) -> Application: + """An application whose collaborators are stand-ins, and whose open reconstruction has an edit on its way.""" + app = Application.__new__(Application) + for name in COLLABORATORS: + setattr(app, name, MagicMock()) + for name in OWN_GESTURES: + setattr(app, name, MagicMock()) + app._reconstruction_coordinator = MagicMock(spec=ReconstructionCoordinator) + app._reconstruction_coordinator.after_edits.side_effect = held_gate + return app + + +@pytest.fixture +def bindings(app: Application) -> ShortcutBindings: + return app._create_shortcut_bindings() + + +class TestAWholeDocumentGestureWaitsForTheEdits(BaseTestSuite): + """A menu item or a key that reads or puts away a whole document runs once the edits before it land.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + press: Callable[[ShortcutBindings], None] + gesture: Callable[[Application], MagicMock] + + test_cases = ( + TestCase( + label="save_project", + press=lambda bindings: bindings.save_project(), + gesture=lambda app: app._project_coordinator.save, + ), + TestCase( + label="save_project_as", + press=lambda bindings: bindings.save_project_as(), + gesture=lambda app: app._project_coordinator.save_as_dialog, + ), + TestCase( + label="new_project", + press=lambda bindings: bindings.new_project(), + gesture=lambda app: app._project_coordinator.new_project_with_confirmation, + ), + TestCase( + label="open_project", + press=lambda bindings: bindings.open_project(), + gesture=lambda app: app._project_coordinator.open_with_confirmation, + ), + TestCase( + label="close_project", + press=lambda bindings: bindings.close_project(), + gesture=lambda app: app._project_coordinator.close_with_confirmation, + ), + TestCase( + label="open_reconstruction", + press=lambda bindings: bindings.open_reconstruction(), + gesture=lambda app: app._reconstruction_coordinator.load_with_confirmation, + ), + TestCase( + label="save_reconstruction", + press=lambda bindings: bindings.save_reconstruction(), + gesture=lambda app: app._reconstruction_coordinator.save, + ), + TestCase( + label="save_reconstruction_as", + press=lambda bindings: bindings.save_reconstruction_as(), + gesture=lambda app: app._reconstruction_coordinator.save_as_dialog, + ), + TestCase( + label="close_reconstruction", + press=lambda bindings: bindings.close_reconstruction(), + gesture=lambda app: app._reconstruction_coordinator.close_with_confirmation, + ), + TestCase( + label="export_wav", + press=lambda bindings: bindings.export_wav(), + gesture=lambda app: app._export_reconstruction_wav_dialog, + ), + TestCase( + label="add_to_sequencer", + press=lambda bindings: bindings.add_reconstruction_to_sequencer(), + gesture=lambda app: app._add_current_reconstruction_to_sequencer, + ), + TestCase( + label="render_song", + press=lambda bindings: bindings.render_song(), + gesture=lambda app: app._render_coordinator.open, + ), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_the_gesture_runs_once_the_edits_have_landed( + self, + test_case: TestCase, + app: Application, + bindings: ShortcutBindings, + held_gate: HeldGate, + ) -> None: + test_case.press(bindings) + test_case.gesture(app).assert_not_called() + + held_gate.release() + + test_case.gesture(app).assert_called_once_with() + + def test_an_export_keeps_the_format_it_was_asked_for( + self, + app: Application, + bindings: ShortcutBindings, + held_gate: HeldGate, + ) -> None: + bindings.export_instruments(ExportFormat.FAMITRACKER) + held_gate.release() + + app._export_reconstruction_instruments_dialog.assert_called_once_with(ExportFormat.FAMITRACKER) + + def test_a_project_export_keeps_the_format_it_was_asked_for( + self, + app: Application, + bindings: ShortcutBindings, + held_gate: HeldGate, + ) -> None: + bindings.export_project(ExportFormat.BITPHASE) + app._project_coordinator.export_project_dialog.assert_not_called() + + held_gate.release() + + app._project_coordinator.export_project_dialog.assert_called_once_with(ExportFormat.BITPHASE) + + def test_undo_leaves_the_wait_to_the_sequencer( + self, + app: Application, + bindings: ShortcutBindings, + ) -> None: + """The sequencer's undo waits on the same edits, so the key reaches it as it stands.""" + assert bindings.undo == app._sequencer_tab.undo + assert bindings.redo == app._sequencer_tab.redo diff --git a/tests/unit/sampletones_application/test_application_retune.py b/tests/unit/sampletones_application/test_application_retune.py index 2a6893301..dcdd7d448 100644 --- a/tests/unit/sampletones_application/test_application_retune.py +++ b/tests/unit/sampletones_application/test_application_retune.py @@ -1,88 +1,133 @@ -from typing import List, Optional +from typing import Final, List, Optional from unittest.mock import MagicMock from sampletones_application.application import Application +from sampletones_application.coordinators.reconstruction import ReconstructionCoordinator +from sampletones_application.logic.history.action import HistoryAction +from sampletones_application.logic.reconstruction.edit import Retune +from sampletones_application.logic.reconstruction.rewrites.steps import RateChange from sampletones_application.services.result import ServiceCanceled from sampletones_application.services.retune import RetunedSample from sampletones_core.project.voices.sample import Sample +OPEN_VOICE_ID: Final[str] = "open" +RATE: Final[int] = 60 + def _sample_double() -> Sample: """A real project sample over a stand-in reconstruction, since the routing tells the kinds apart.""" return Sample(name="lead", reconstruction=MagicMock()) -def _retuned(voice_id: str, rate: int) -> RetunedSample: +def _retuned(voice_id: str, rate: int, source: MagicMock) -> RetunedSample: reconstruction = MagicMock() reconstruction.config.nes_frequency = rate - return RetunedSample(voice_id=voice_id, reconstruction=reconstruction) + return RetunedSample(voice_id=voice_id, reconstruction=reconstruction, source=source) def _app( current_rate: int, sample: Optional[Sample], - open_reconstruction: Optional[MagicMock] = None, + *, + open_voice_id: Optional[str], ) -> Application: app = Application.__new__(Application) app.project_manager = MagicMock() app.project_manager.current.settings.nes_frequency = current_rate app.project_manager.current.voices.get.return_value = sample app.reconstruction_manager = MagicMock() - app.reconstruction_manager.reconstruction = open_reconstruction + app.reconstruction_manager.voice_id = open_voice_id app.history = MagicMock() app.project_controller = MagicMock() app._sequencer_tab = MagicMock() - app._reconstructions_tab = MagicMock() + app._reconstruction_coordinator = MagicMock(spec=ReconstructionCoordinator) return app class TestApplyRetunedSample: def test_swaps_the_reconstruction_when_the_rate_matches(self) -> None: - app = _app(current_rate=60, sample=_sample_double()) - retuned = _retuned("lead", 60) + sample = _sample_double() + app = _app(current_rate=RATE, sample=sample, open_voice_id=None) + retuned = _retuned("lead", RATE, source=sample.reconstruction) app._apply_retuned_sample(retuned) app.project_controller.replace_sample_reconstruction.assert_called_once_with("lead", retuned.reconstruction) def test_discards_a_stale_result_from_a_superseded_rate(self) -> None: - app = _app(current_rate=30, sample=_sample_double()) - retuned = _retuned("lead", 60) + sample = _sample_double() + app = _app(current_rate=30, sample=sample, open_voice_id=None) - app._apply_retuned_sample(retuned) + app._apply_retuned_sample(_retuned("lead", RATE, source=sample.reconstruction)) app.project_controller.replace_sample_reconstruction.assert_not_called() def test_ignores_a_removed_sample(self) -> None: - app = _app(current_rate=60, sample=None) - retuned = _retuned("lead", 60) + app = _app(current_rate=RATE, sample=None, open_voice_id=None) - app._apply_retuned_sample(retuned) + app._apply_retuned_sample(_retuned("lead", RATE, source=MagicMock())) app.project_controller.replace_sample_reconstruction.assert_not_called() - def test_rebinds_the_open_editor_when_it_shows_the_sample(self) -> None: - open_reconstruction = MagicMock() + def test_folds_the_retune_into_the_rate_change_entry(self) -> None: sample = _sample_double() - sample.reconstruction = open_reconstruction - app = _app(current_rate=60, sample=sample, open_reconstruction=open_reconstruction) - retuned = _retuned("lead", 60) + app = _app(current_rate=RATE, sample=sample, open_voice_id=None) - app._apply_retuned_sample(retuned) + app._apply_retuned_sample(_retuned("lead", RATE, source=sample.reconstruction)) - app.reconstruction_manager.apply_edited.assert_called_once_with(retuned.reconstruction) - app._reconstructions_tab.update_reconstruction.assert_called_once_with(refit_waveform=True) + action = app.history.transaction.call_args.args[0] + assert action is HistoryAction.SET_NES_FREQUENCY + assert app.history.transaction.call_args.kwargs["coalesce"] == (RATE,) - def test_leaves_the_editor_alone_when_a_different_sample_is_open(self) -> None: + def test_a_sample_opened_since_the_batch_started_takes_the_rate_as_a_step(self) -> None: + """The open document changes one step at a time, so the rate waits for the edits made there.""" sample = _sample_double() - sample.reconstruction = MagicMock() - app = _app(current_rate=60, sample=sample, open_reconstruction=MagicMock()) - retuned = _retuned("lead", 60) + app = _app(current_rate=RATE, sample=sample, open_voice_id=sample.id) - app._apply_retuned_sample(retuned) + app._apply_retuned_sample(_retuned(sample.id, RATE, source=sample.reconstruction)) + + app._reconstruction_coordinator.request_rewrite.assert_called_once_with(RateChange(nes_frequency=RATE)) + app.project_controller.replace_sample_reconstruction.assert_not_called() + + def test_another_samples_retune_leaves_the_open_document_alone(self) -> None: + sample = _sample_double() + app = _app(current_rate=RATE, sample=sample, open_voice_id=OPEN_VOICE_ID) + + app._apply_retuned_sample(_retuned("lead", RATE, source=sample.reconstruction)) + + app._reconstruction_coordinator.request_rewrite.assert_not_called() + + def test_a_discarded_result_reaches_no_document(self) -> None: + sample = _sample_double() + app = _app(current_rate=30, sample=sample, open_voice_id=sample.id) + + app._apply_retuned_sample(_retuned(sample.id, RATE, source=sample.reconstruction)) + + app._reconstruction_coordinator.request_rewrite.assert_not_called() - app.reconstruction_manager.apply_edited.assert_not_called() - app._reconstructions_tab.update_reconstruction.assert_not_called() + +class TestASampleChangedSinceTheBatchStarted: + """A batch retunes what each sample held as it started, so a sample edited since is retuned afresh.""" + + def test_it_is_retuned_from_what_it_now_holds(self) -> None: + sample = _sample_double() + app = _app(current_rate=RATE, sample=sample, open_voice_id=None) + + app._apply_retuned_sample(_retuned("lead", RATE, source=MagicMock())) + + app.project_controller.replace_sample_reconstruction.assert_called_once_with( + "lead", + sample.reconstruction.with_nes_frequency.return_value, + ) + + def test_a_sample_already_at_the_rate_is_left_as_it_is(self) -> None: + sample = _sample_double() + sample.reconstruction.with_nes_frequency.return_value = sample.reconstruction + app = _app(current_rate=RATE, sample=sample, open_voice_id=None) + + app._apply_retuned_sample(_retuned("lead", RATE, source=MagicMock())) + + app.project_controller.replace_sample_reconstruction.assert_not_called() def _sample(voice_id: str, rate: int) -> Sample: @@ -94,63 +139,84 @@ def _sample(voice_id: str, rate: int) -> Sample: def _app_for_rate( samples: List[Sample], - open_reconstruction: Optional[MagicMock], + open_sample: Optional[Sample], running: bool = False, ) -> Application: app = Application.__new__(Application) app.project_manager = MagicMock() app.project_manager.current.voices = samples + app.project_manager.current.voice.side_effect = {sample.id: sample for sample in samples}.get app.reconstruction_manager = MagicMock() - app.reconstruction_manager.reconstruction = open_reconstruction + app.reconstruction_manager.voice_id = None if open_sample is None else open_sample.id app.retune_service = MagicMock() app.retune_service.start.return_value = True app.retune_service.is_running.return_value = running app.status_bar = MagicMock() app.language_manager = MagicMock() app._reconstructions_tab = MagicMock() + app._reconstruction_coordinator = MagicMock(spec=ReconstructionCoordinator) return app -class TestRetuneDim: - def test_dims_the_open_reconstruction_when_it_will_be_retuned(self) -> None: - open_sample = _sample("open", 30) - app = _app_for_rate( - [open_sample, _sample("other", 30)], - open_reconstruction=open_sample.reconstruction, - ) +class TestTheOpenSampleTakesTheRateAsAStep: + """The sample open on the Reconstructions tab is re-timed as a step of its document, after the edits made there.""" - app._retune_samples_for_rate(60) + def test_the_open_sample_asks_for_the_rate(self) -> None: + open_sample = _sample(OPEN_VOICE_ID, 30) + app = _app_for_rate([open_sample, _sample("other", 30)], open_sample=open_sample) - app._reconstructions_tab.set_reconstruction_dimmed.assert_called_once_with(True) + app._retune_samples_for_rate(RATE) - def test_does_not_dim_when_the_open_sample_already_matches(self) -> None: - open_sample = _sample("open", 60) - app = _app_for_rate( - [open_sample, _sample("other", 30)], - open_reconstruction=open_sample.reconstruction, - ) + app._reconstruction_coordinator.request_rewrite.assert_called_once_with(RateChange(nes_frequency=RATE)) + + def test_the_open_sample_is_left_out_of_the_batch(self) -> None: + open_sample = _sample(OPEN_VOICE_ID, 30) + other = _sample("other", 30) + app = _app_for_rate([open_sample, other], open_sample=open_sample) - app._retune_samples_for_rate(60) + app._retune_samples_for_rate(RATE) - app._reconstructions_tab.set_reconstruction_dimmed.assert_not_called() + targets, rate = app.retune_service.start.call_args.args + assert [voice_id for voice_id, _reconstruction in targets] == [other.id] + assert rate == RATE - def test_does_not_dim_when_no_reconstruction_is_open(self) -> None: - app = _app_for_rate([_sample("a", 30), _sample("b", 30)], open_reconstruction=None) + def test_no_sample_open_asks_for_no_step(self) -> None: + app = _app_for_rate([_sample("a", 30), _sample("b", 30)], open_sample=None) - app._retune_samples_for_rate(60) + app._retune_samples_for_rate(RATE) - app._reconstructions_tab.set_reconstruction_dimmed.assert_not_called() + app._reconstruction_coordinator.request_rewrite.assert_not_called() + assert len(app.retune_service.start.call_args.args[0]) == 2 - def test_restores_the_dim_when_the_batch_finishes(self) -> None: - app = _app_for_rate([], open_reconstruction=None, running=False) + def test_the_status_clears_when_the_batch_finishes(self) -> None: + app = _app_for_rate([], open_sample=None, running=False) app._on_retune_result(ServiceCanceled()) - app._reconstructions_tab.set_reconstruction_dimmed.assert_called_once_with(False) + app.status_bar.set.assert_called_once_with("") - def test_keeps_the_dim_while_the_batch_is_running(self) -> None: - app = _app_for_rate([], open_reconstruction=None, running=True) + def test_the_status_stands_while_the_batch_is_running(self) -> None: + app = _app_for_rate([], open_sample=None, running=True) app._on_retune_result(ServiceCanceled()) - app._reconstructions_tab.set_reconstruction_dimmed.assert_not_called() + app.status_bar.set.assert_not_called() + + +class TestARetuneOfTheOpenSampleIsRecorded: + """A retune landing on the open sample joins the rate change it follows.""" + + def test_it_is_recorded_as_the_rate_change(self) -> None: + sample = _sample_double() + app = _app(current_rate=RATE, sample=sample, open_voice_id=sample.id) + app.project_manager.current.voice.return_value = sample + retuned = MagicMock() + + app._on_reconstruction_updated(Retune(reconstruction=retuned, nes_frequency=RATE)) + + transaction = app.history.transaction.call_args + assert transaction.args[0] is HistoryAction.SET_NES_FREQUENCY + assert transaction.kwargs["coalesce"] == (RATE,) + assert transaction.kwargs["detail"] is app._sequencer_tab.nes_frequency_detail.return_value + app._sequencer_tab.nes_frequency_detail.assert_called_once_with(RATE) + app.project_controller.replace_sample_reconstruction.assert_called_once_with(sample.id, retuned) diff --git a/tests/unit/sampletones_application/test_application_sample_rebind.py b/tests/unit/sampletones_application/test_application_sample_rebind.py deleted file mode 100644 index c57350919..000000000 --- a/tests/unit/sampletones_application/test_application_sample_rebind.py +++ /dev/null @@ -1,65 +0,0 @@ -from typing import Optional -from unittest.mock import MagicMock - -from sampletones_application.application import Application -from sampletones_core.project.voices.sample import Sample - - -def _sample_double() -> Sample: - """A real project sample over a stand-in reconstruction, since the routing tells the kinds apart.""" - return Sample(name="lead", reconstruction=MagicMock()) - - -def _app( - sample: Optional[Sample], - open_reconstruction: Optional[MagicMock], -) -> Application: - app = Application.__new__(Application) - app.project_manager = MagicMock() - app.project_manager.current.voice.return_value = sample - app.reconstruction_manager = MagicMock() - app.reconstruction_manager.reconstruction = open_reconstruction - app._reconstructions_tab = MagicMock() - return app - - -class TestRebindReplacedSample: - def test_rebinds_the_editor_showing_the_replaced_sample(self) -> None: - outgoing = MagicMock() - sample = _sample_double() - sample.reconstruction = outgoing - app = _app(sample=sample, open_reconstruction=outgoing) - incoming = MagicMock() - - app._rebind_replaced_sample("bass-id", incoming) - - app.reconstruction_manager.apply_edited.assert_called_once_with(incoming) - app._reconstructions_tab.update_reconstruction.assert_called_once() - - def test_leaves_the_editor_alone_when_a_different_sample_is_open(self) -> None: - sample = _sample_double() - sample.reconstruction = MagicMock() - app = _app(sample=sample, open_reconstruction=MagicMock()) - - app._rebind_replaced_sample("bass-id", MagicMock()) - - app.reconstruction_manager.apply_edited.assert_not_called() - app._reconstructions_tab.update_reconstruction.assert_not_called() - - def test_leaves_the_editor_alone_when_no_document_is_open(self) -> None: - sample = _sample_double() - sample.reconstruction = MagicMock() - app = _app(sample=sample, open_reconstruction=None) - - app._rebind_replaced_sample("bass-id", MagicMock()) - - app.reconstruction_manager.apply_edited.assert_not_called() - app._reconstructions_tab.update_reconstruction.assert_not_called() - - def test_ignores_an_unknown_sample(self) -> None: - app = _app(sample=None, open_reconstruction=MagicMock()) - - app._rebind_replaced_sample("gone", MagicMock()) - - app.reconstruction_manager.apply_edited.assert_not_called() - app._reconstructions_tab.update_reconstruction.assert_not_called() diff --git a/tests/unit/sampletones_application/test_startup.py b/tests/unit/sampletones_application/test_startup.py index e18daad39..2df2f3ca7 100644 --- a/tests/unit/sampletones_application/test_startup.py +++ b/tests/unit/sampletones_application/test_startup.py @@ -1,7 +1,7 @@ from contextlib import ExitStack, contextmanager from pathlib import Path -from typing import Any, Callable, Dict, Final, FrozenSet, Generator, List, Tuple, Union -from unittest.mock import PropertyMock, patch +from typing import Any, Callable, Dict, Final, FrozenSet, Generator, List, Optional, Tuple, Union +from unittest.mock import MagicMock, PropertyMock, patch import dearpygui.dearpygui as dpg import pytest @@ -11,10 +11,13 @@ from sampletones_application.config.managers.session import SessionManager from sampletones_application.config.profile import UserProfile from sampletones_application.constants.conversion import MAX_STEM_SOURCES +from sampletones_application.constants.instruments import INSTRUMENT_CHANNEL from sampletones_application.constants.keybindings import DEFAULT_SCHEME_NAME from sampletones_application.constants.output import OutputKind from sampletones_application.constants.sources import SettingsField, SourceKind from sampletones_application.logic.history.action import HistoryAction +from sampletones_application.logic.reconstruction.edit import ChannelEdit +from sampletones_application.logic.reconstruction.rewrites.steps import ChannelChange from sampletones_application.tags.compose import compose_tag from sampletones_application.tags.general import ( SUF_BUTTON, @@ -59,20 +62,29 @@ ) from sampletones_application.utils.parallelization.thread import SingleThreadExecutor from sampletones_application.view_model.main.converter import ConversionPhase, ConverterViewModel +from sampletones_application.view_model.reconstruction.envelopes import ChannelEnvelopesViewModel +from sampletones_application.view_model.reconstruction.instruments import ReconstructionInstrumentsViewModel from sampletones_application.view_model.shared.stems import StemRowViewModel from sampletones_core.configs import Config from sampletones_core.constants.algorithm import UNIT_DRIVE -from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.features.envelope import Envelope +from sampletones_core.project.voices.instrument import Instrument +from sampletones_core.project.voices.sample import Sample from sampletones_core.reconstructions import Reconstruction from sampletones_core.reconstructions.converter.paths import get_audio_files from sampletones_core.structures.tree import FileSystemNode, NodeType +from sampletones_shared.paths.extensions import EXT_FILE_PROJECT from sampletones_shared.paths.user import CONFIG_PATH, LIBRARY_DIRECTORY, RECONSTRUCTIONS_DIRECTORY +from tests.conftest import ReconstructionFactory +from tests.suite.application import HeldQueue, held_queue from tests.suite.gestures import DOUBLE_CLICKED, click_row_name REBOUND_UNDO: Final[Dict[str, str]] = {"Undo": "Ctrl+Alt+U"} DRAG_PAYLOAD_SLOT: Final[int] = 3 LOUD_DRIVE: Final[float] = 1.75 UNBUILT_ROW: Final[str] = "browser.row.unbuilt" +EDITED_VOLUME: Final[Envelope[int]] = Envelope[int](items=(7, 7)) _DPG_DISPLAY_FUNCTIONS = [ "create_context", @@ -95,6 +107,8 @@ _VIEWPORT_CLIENT_WIDTH: Final[int] = 1280 _VIEWPORT_CLIENT_HEIGHT: Final[int] = 720 +__all__ = ["held_queue"] + def _display_patches() -> List[Any]: display_patches = [patch(f"dearpygui.dearpygui.{name}", return_value=None) for name in _DPG_DISPLAY_FUNCTIONS] @@ -288,6 +302,30 @@ def test_library_load_delegates_to_coordinator(self, app: Application) -> None: load_library_file.assert_called_once_with(Path("last.ins")) +@pytest.fixture +def embedded_sample( + app: Application, + reconstruction_factory: ReconstructionFactory, +) -> Sample: + """A sample added to a new project and opened on the Reconstructions tab, as Edit opens it.""" + app.project_controller.new() + with app.history.transaction(HistoryAction.ADD_SAMPLE): + sample = app.project_controller.add_sample(reconstruction_factory(), "Lead") + app._reconstruction_coordinator.open_project_voice(sample.id) + return sample + + +@pytest.fixture +def embedded_instrument(app: Application) -> Instrument: + """An instrument added to a new project and opened on the Reconstructions tab, as Edit opens it.""" + app.project_controller.new() + app._add_instrument() + instrument = app.project_manager.current.voices[0] + assert isinstance(instrument, Instrument) + app._reconstruction_coordinator.open_project_voice(instrument.id) + return instrument + + class TestReconstructionSaveAsDetachment: """End-to-end proof that Save As severs a project sample from the open document. @@ -296,35 +334,20 @@ class TestReconstructionSaveAsDetachment: while the project's sample keeps its original reconstruction object. """ - def _embed_sample( - self, - app: Application, - reconstruction_factory: Callable[[], Reconstruction], - ) -> Any: - app.project_controller.new() - reconstruction = reconstruction_factory() - with app.history.transaction(HistoryAction.ADD_SAMPLE): - sample = app.project_controller.add_sample(reconstruction, "Lead") - app._edit_project_voice(sample.id) - return sample - def test_embedded_reconstruction_is_owned_and_not_saveable( self, app: Application, - reconstruction_factory: Callable[[], Reconstruction], + embedded_sample: Sample, ) -> None: - sample = self._embed_sample(app, reconstruction_factory) - - assert app._owning_project_sample() is sample + assert app._owning_project_sample() is embedded_sample assert not app._reconstruction_coordinator.is_saveable() assert not app._build_menu_bar_viewmodel().reconstruction_saveable def test_embedded_reconstruction_needs_no_save_prompt_when_edited( self, app: Application, - reconstruction_factory: Callable[[], Reconstruction], + embedded_sample: Sample, ) -> None: - self._embed_sample(app, reconstruction_factory) app.reconstruction_manager.mark_updated() assert app._reconstruction_coordinator.is_unsaved() @@ -333,11 +356,10 @@ def test_embedded_reconstruction_needs_no_save_prompt_when_edited( def test_save_as_detaches_open_document_from_the_project( self, app: Application, - reconstruction_factory: Callable[[], Reconstruction], + embedded_sample: Sample, tmp_path: Path, ) -> None: - sample = self._embed_sample(app, reconstruction_factory) - original = sample.reconstruction + original = embedded_sample.reconstruction app.reconstruction_manager.save_reconstruction_as(tmp_path / "lead.stn") @@ -345,7 +367,7 @@ def test_save_as_detaches_open_document_from_the_project( assert app._reconstruction_coordinator.is_saveable() assert app._build_menu_bar_viewmodel().reconstruction_saveable assert app.reconstruction_manager.reconstruction is not original - assert sample.reconstruction is original + assert embedded_sample.reconstruction is original assert original in [sample.reconstruction for sample in app.project_manager.current.voices] @@ -401,6 +423,349 @@ def test_embedded_sample_is_a_detached_copy( assert not app._editing_project_sample() +class TestTheReconstructionsTabFollowsTheProject: + """The Reconstructions tab knows the voice it shows by its id, so a restore and a removal reach it. + + An undo or a redo keeping the voice shows it as the project now holds it, and a change that takes + the voice out of the project, or puts another project in place, empties the tab. + """ + + @staticmethod + def _edit(app: Application, reconstruction_factory: ReconstructionFactory) -> Reconstruction: + """A regenerated instrument landing on the open sample, the way a drag on the panel lands.""" + edited = reconstruction_factory() + app._reconstruction_coordinator.apply_edit( + ChannelEdit( + reconstruction=edited, + channel_name=ChannelName.PULSE1, + feature_key=FeatureKey.VOLUME, + ) + ) + return edited + + @staticmethod + def _shows_nothing(app: Application, held_queue: HeldQueue) -> bool: + """Whether the tab stands empty once the close it queued has run.""" + held_queue.drain() + return ( + app.reconstruction_manager.current_reconstruction is None + and not app._reconstruction_coordinator.is_loaded() + ) + + @staticmethod + def _drawn_views(app: Application) -> List[ReconstructionInstrumentsViewModel]: + """Every view the instruments panel is handed from here on.""" + views: List[ReconstructionInstrumentsViewModel] = [] + app._reconstructions_tab._reconstruction_instruments_logic.on_view_changed = views.append + return views + + @staticmethod + def _drawn_envelopes(app: Application) -> List[Optional[ChannelEnvelopesViewModel]]: + """Every set of envelopes the instruments panel is handed from here on.""" + drawn: List[Optional[ChannelEnvelopesViewModel]] = [] + app._reconstructions_tab._reconstruction_instruments_logic.on_feature_data_changed = drawn.append + return drawn + + def test_an_undo_shows_the_reconstruction_it_restores( + self, + app: Application, + embedded_sample: Sample, + reconstruction_factory: ReconstructionFactory, + ) -> None: + original = embedded_sample.reconstruction + self._edit(app, reconstruction_factory) + + app.history.undo() + + assert app.reconstruction_manager.reconstruction is original + + def test_the_sample_an_undo_keeps_still_owns_the_tab( + self, + app: Application, + embedded_sample: Sample, + reconstruction_factory: ReconstructionFactory, + ) -> None: + """The title and the next edit reach the sample, so the tab stays a view of the project.""" + self._edit(app, reconstruction_factory) + + app.history.undo() + + owner = app._owning_project_sample() + title = app._reconstruction_title_part() + assert owner is not None + assert owner.id == embedded_sample.id + assert title is not None + assert title.included + + def test_an_undo_redraws_the_instruments_panel( + self, + app: Application, + embedded_sample: Sample, + reconstruction_factory: ReconstructionFactory, + ) -> None: + self._edit(app, reconstruction_factory) + drawn = self._drawn_envelopes(app) + + app.history.undo() + + assert drawn == [app.reconstruction_manager.current_features] + + def test_a_redo_shows_the_edit_again( + self, + app: Application, + embedded_sample: Sample, + reconstruction_factory: ReconstructionFactory, + ) -> None: + edited = self._edit(app, reconstruction_factory) + app.history.undo() + + app.history.redo() + + owner = app._owning_project_sample() + assert app.reconstruction_manager.reconstruction is edited + assert owner is not None + assert owner.id == embedded_sample.id + + def test_an_undo_of_an_instrument_edit_draws_the_envelope_it_restores( + self, + app: Application, + embedded_instrument: Instrument, + ) -> None: + before = embedded_instrument.instrument_features().volume + instruments_logic = app._reconstructions_tab._reconstruction_instruments_logic + instruments_logic.handle_envelope_changed(INSTRUMENT_CHANNEL, FeatureKey.VOLUME, EDITED_VOLUME) + drawn = self._drawn_envelopes(app) + + app.history.undo() + + assert drawn + envelopes = drawn[-1] + assert envelopes is not None + assert envelopes[INSTRUMENT_CHANNEL].volume == before + + def test_an_undo_taking_the_sample_out_empties_the_tab( + self, + app: Application, + embedded_sample: Sample, + held_queue: HeldQueue, + ) -> None: + app.history.undo() + + assert app.project_manager.current.voice(embedded_sample.id) is None + assert self._shows_nothing(app, held_queue) + + def test_a_redo_bringing_the_sample_back_leaves_the_tab_empty( + self, + app: Application, + embedded_sample: Sample, + held_queue: HeldQueue, + ) -> None: + """A voice that comes back is the reader's to open again.""" + app.history.undo() + + app.history.redo() + + assert app.project_manager.current.voice(embedded_sample.id) is not None + assert self._shows_nothing(app, held_queue) + + def test_removing_the_open_sample_empties_the_tab( + self, + app: Application, + embedded_sample: Sample, + held_queue: HeldQueue, + ) -> None: + app._sequencer_tab._voices.remove(embedded_sample.id) + + assert self._shows_nothing(app, held_queue) + + def test_removing_the_open_instrument_empties_the_panel( + self, + app: Application, + embedded_instrument: Instrument, + ) -> None: + views = self._drawn_views(app) + + app._sequencer_tab._voices.remove(embedded_instrument.id) + + assert views + assert views[-1].instrument is None + assert not views[-1].reconstruction_loaded + + def test_closing_the_project_empties_the_tab( + self, + app: Application, + embedded_sample: Sample, + held_queue: HeldQueue, + ) -> None: + app.project_controller.close() + + assert self._shows_nothing(app, held_queue) + + def test_reopening_the_saved_project_empties_the_tab( + self, + app: Application, + embedded_sample: Sample, + held_queue: HeldQueue, + tmp_path: Path, + ) -> None: + """The file keeps the voice's id, so the reopened project holds a voice the tab no longer shows.""" + path = tmp_path / f"song{EXT_FILE_PROJECT}" + app.project_controller.save(path) + + app.project_controller.load(path) + + assert isinstance(app.project_manager.current.voice(embedded_sample.id), Sample) + assert self._shows_nothing(app, held_queue) + + def test_a_reconstruction_opened_from_a_file_outlasts_a_new_and_a_closed_project( + self, + app: Application, + reconstruction_factory: ReconstructionFactory, + held_queue: HeldQueue, + tmp_path: Path, + ) -> None: + path = tmp_path / "lead.stn" + reconstruction_factory().save(path) + app.reconstruction_manager.load_reconstruction(path) + + app.project_controller.new() + app.project_controller.close() + + held_queue.drain() + assert app.reconstruction_manager.filepath == path + + +class TestAGestureOnTheWholeDocumentWaitsForTheEdits: + """A gesture that reads or puts away the open document runs once the edit on its way has landed. + + The edit's rebuild is held, so each gesture meets an edit still on its way, the way a click + right after a drag meets it. + """ + + @pytest.fixture + def edit_on_its_way( + self, + app: Application, + embedded_sample: Sample, + held_queue: HeldQueue, + ) -> Sample: + """The open sample with a volume edit rebuilt and its result held until the case drains the queue.""" + held_queue.drain() + with patch.object( + app.regeneration_service._executor, + "execute", + side_effect=lambda target, wait: target() or True, + ): + app._reconstruction_coordinator.request_rewrite( + ChannelChange( + channel_name=ChannelName.PULSE1, + feature_key=FeatureKey.VOLUME, + envelopes={FeatureKey.VOLUME: EDITED_VOLUME}, + initial_pitch=None, + ) + ) + + assert app.reconstruction_rewrites.is_busy + return embedded_sample + + @staticmethod + def _edited(reconstruction: Optional[Reconstruction]) -> bool: + assert reconstruction is not None + return ( + reconstruction.export()[ChannelName.PULSE1].volume.items[: len(EDITED_VOLUME.items)] == EDITED_VOLUME.items + ) + + def test_a_browser_load_waits( + self, + app: Application, + edit_on_its_way: Sample, + held_queue: HeldQueue, + reconstruction_factory: ReconstructionFactory, + tmp_path: Path, + ) -> None: + path = tmp_path / "other.stn" + reconstruction_factory().save(path) + + app._reconstructions_tab._browser_panel.on_load_reconstruction(path) + assert app.reconstruction_manager.voice_id == edit_on_its_way.id + held_queue.drain() + + assert app.reconstruction_manager.filepath == path + sample = app.project_manager.current.voice(edit_on_its_way.id) + assert isinstance(sample, Sample) + assert self._edited(sample.reconstruction) + + def test_loading_a_conversion_waits( + self, + app: Application, + edit_on_its_way: Sample, + held_queue: HeldQueue, + reconstruction_factory: ReconstructionFactory, + tmp_path: Path, + ) -> None: + path = tmp_path / "converted.stn" + reconstruction_factory().save(path) + + app._main_tab._hooks.on_load_file(path) + assert app.reconstruction_manager.voice_id == edit_on_its_way.id + held_queue.drain() + + assert app.reconstruction_manager.filepath == path + + def test_opening_another_voice_waits( + self, + app: Application, + edit_on_its_way: Sample, + held_queue: HeldQueue, + reconstruction_factory: ReconstructionFactory, + ) -> None: + with app.history.transaction(HistoryAction.ADD_SAMPLE): + other = app.project_controller.add_sample(reconstruction_factory(), "Other") + + app._sequencer_tab._on_edit_voice_requested(other.id) + assert app.reconstruction_manager.voice_id == edit_on_its_way.id + held_queue.drain() + + sample = app.project_manager.current.voice(edit_on_its_way.id) + assert isinstance(sample, Sample) + assert app.reconstruction_manager.voice_id == other.id + assert self._edited(sample.reconstruction) + + def test_removing_the_open_voice_waits( + self, + app: Application, + edit_on_its_way: Sample, + held_queue: HeldQueue, + ) -> None: + """The edit is recorded before the removal, so an undo brings the sample back as the reader left it.""" + app._sequencer_tab._sequencer_voices_panel.on_remove_requested(edit_on_its_way.id) + assert app.project_manager.current.voice(edit_on_its_way.id) is not None + held_queue.drain() + + assert app.project_manager.current.voice(edit_on_its_way.id) is None + assert [entry.action for entry in app.history.entries[-2:]] == [ + HistoryAction.EDIT_RECONSTRUCTION, + HistoryAction.REMOVE_VOICE, + ] + + def test_exporting_an_instrument_waits( + self, + app: Application, + edit_on_its_way: Sample, + held_queue: HeldQueue, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + exports = MagicMock() + monkeypatch.setattr(app._reconstructions_tab, "_instrument_exports", exports) + + app._reconstructions_tab._reconstruction_instruments_panel.on_instrument_export(ChannelName.PULSE1) + exports.request.assert_not_called() + held_queue.drain() + + source = exports.request.call_args.args[0] + assert source.features.volume.items[: len(EDITED_VOLUME.items)] == EDITED_VOLUME.items + + def _press_shortcut(app: Application, shortcut_id: ShortcutId) -> None: """Routes the press the scheme in place gives an action, so a rebind carries the case with it.""" combination = app._shortcut_source.shortcut(shortcut_id).combination diff --git a/tests/unit/sampletones_application/ui/elements/graphs/test_waveform.py b/tests/unit/sampletones_application/ui/elements/graphs/test_waveform.py index debca4edc..51f3d7843 100644 --- a/tests/unit/sampletones_application/ui/elements/graphs/test_waveform.py +++ b/tests/unit/sampletones_application/ui/elements/graphs/test_waveform.py @@ -280,6 +280,27 @@ def test_set_dimmed_shows_then_clears_the_status_message(self) -> None: graph.set_reconstruction_dimmed(False) graph._status_bar.set.assert_called_with("") + def test_new_data_keeps_the_reconstruction_dimmed(self, monkeypatch: pytest.MonkeyPatch) -> None: + """An edit landing while later ones are still on their way redraws the waveform still faded.""" + graph = _graph() + graph.set_reconstruction_dimmed(True) + monkeypatch.setattr(graph, "clear_layers", MagicMock()) + monkeypatch.setattr(graph, "_restate_clock_ticks", MagicMock()) + monkeypatch.setattr(graph, "_display_layers", MagicMock(return_value=[])) + graph._x_range = (0.0, 1.0) + waveform_data = WaveformData( + original_audio=None, + approximation=np.zeros(4), + approximations={}, + coefficient=1.0, + frame_length=1, + sample_rate=4, + ) + + graph.load_waveform_data(waveform_data) + + assert graph._reconstruction_dimmed is True + class TestAClickReportsASampleOfDrawnAudio: """The graph reports the sample a click named while it draws the audio its player sounds.""" diff --git a/tests/unit/sampletones_application/ui/elements/stems/test_list.py b/tests/unit/sampletones_application/ui/elements/stems/test_list.py index 34df2c7eb..e60d6d532 100644 --- a/tests/unit/sampletones_application/ui/elements/stems/test_list.py +++ b/tests/unit/sampletones_application/ui/elements/stems/test_list.py @@ -578,6 +578,21 @@ def test_the_rule_the_button_reads_is_the_one_every_way_out_reads( assert alone is False assert stems_list.lets_a_row_go is True + def test_the_reader_s_own_frames_leave_the_last_recording_in_place( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + """The frames an edit wrote stand as a row of their own, and no removal takes them out.""" + stems_list = build(layout_config, keeps_last_row=True) + bass = row("bass") + edits = row("edits", stem_id=None, kind=SourceKind.EDITS) + + stems_list.update_view(view(bass, edits)) + + assert not dpg.is_item_enabled(row_tag(bass, SUF_BUTTON)) + assert stems_list.lets_a_row_go is False + def test_a_list_that_keeps_no_row_lets_the_last_one_go( self, dpg_context: None, layout_config: LayoutConfig ) -> None: diff --git a/tests/unit/sampletones_application/ui/elements/test_seeded.py b/tests/unit/sampletones_application/ui/elements/test_seeded.py new file mode 100644 index 000000000..c62981a40 --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/test_seeded.py @@ -0,0 +1,87 @@ +from typing import Final, Iterator, List +from unittest.mock import MagicMock, patch + +import dearpygui.dearpygui as dpg +import pytest + +from sampletones_application.layout.primitives import DialogGeometry +from sampletones_application.ui.elements.seeded import GUISeededDialogWindow +from sampletones_application.utils.gui.keyboard import KeyRouter +from sampletones_application.utils.gui.modal_queue import ModalQueue +from sampletones_application.utils.gui.shortcuts.source import ShortcutSource +from tests.suite.frames import Frames, held_frames + +__all__ = ["held_frames"] + +MODULE: Final[str] = "sampletones_application.ui.elements.window" +TAG: Final[str] = "test.dialog.window.seeded" +STANDING_TAG: Final[str] = "test.dialog.window.standing" +FIRST_SEED: Final[str] = "first" +NEWER_SEED: Final[str] = "newer" +PROBE_WIDTH: Final[int] = 200 +PROBE_HEIGHT: Final[int] = 100 +VIEWPORT_WIDTH: Final[int] = 800 +VIEWPORT_HEIGHT: Final[int] = 600 + + +class ProbeSeededWindow(GUISeededDialogWindow[str]): + """A dialog drawing one word, which records every word it drew.""" + + def __init__(self, router: KeyRouter) -> None: + self.drawn: List[str] = [] + super().__init__( + TAG, + DialogGeometry(width=PROBE_WIDTH, height=PROBE_HEIGHT), + subject="probe", + key_router=router, + shortcut_source=MagicMock(spec=ShortcutSource), + ) + + def create_window(self) -> None: + with self.dialog_window(label="probe", on_close=None): + dpg.add_text(self.view_model) + + self.drawn.append(self.view_model) + + def _render(self) -> None: + self.drawn.append(self.view_model) + + +@pytest.fixture(name="dpg_context") +def dpg_context_fixture() -> Iterator[None]: + dpg.create_context() + with ( + patch(f"{MODULE}.ThemeRegistry"), + patch(f"{MODULE}.center_when_settled"), + patch.object(dpg, "get_viewport_client_width", return_value=VIEWPORT_WIDTH), + patch.object(dpg, "get_viewport_client_height", return_value=VIEWPORT_HEIGHT), + ): + try: + yield + finally: + dpg.destroy_context() + + +class TestASeededWindowWaitingForTheScreen: + """A dialog opened while another modal stands keeps the newest seed until the screen is free.""" + + @pytest.fixture(name="window") + def window_fixture(self, dpg_context: None) -> ProbeSeededWindow: + ModalQueue.open(STANDING_TAG, lambda: None) + window = ProbeSeededWindow(KeyRouter()) + window.open(FIRST_SEED) + return window + + def test_a_new_seed_draws_nothing_while_the_window_waits(self, window: ProbeSeededWindow) -> None: + window.update_view(NEWER_SEED) + + assert not window.drawn + assert not dpg.does_item_exist(TAG) + + def test_the_window_opens_on_the_newest_seed(self, window: ProbeSeededWindow, held_frames: Frames) -> None: + window.update_view(NEWER_SEED) + + ModalQueue.leave(STANDING_TAG) + held_frames.render() + + assert window.drawn == [NEWER_SEED] diff --git a/tests/unit/sampletones_application/ui/elements/test_window.py b/tests/unit/sampletones_application/ui/elements/test_window.py index 8f5e2c594..8847651c1 100644 --- a/tests/unit/sampletones_application/ui/elements/test_window.py +++ b/tests/unit/sampletones_application/ui/elements/test_window.py @@ -7,14 +7,20 @@ from sampletones_application.layout.primitives import DEARPYGUI_MAXIMUM_WINDOW_SIZE, DialogGeometry from sampletones_application.ui.elements.window import GUIWindow from sampletones_shared.types.callback import VoidCallback +from tests.suite.frames import Frames, held_frames MODULE: Final[str] = "sampletones_application.ui.elements.window" TAG: Final[str] = "test.dialog.window.probe" +OTHER_TAG: Final[str] = "test.dialog.window.other" +PROMPT_TAG: Final[str] = "test.dialog.window.prompt" +REPORT_TAG: Final[str] = "test.dialog.window.report" STATED_WIDTH: Final[int] = 460 STATED_HEIGHT: Final[int] = 200 VIEWPORT_WIDTH: Final[int] = 1280 VIEWPORT_HEIGHT: Final[int] = 800 +__all__ = ["held_frames"] + class ProbeWindow(GUIWindow): """A dialog whose content stretches across the window, the shape a stated width has to hold.""" @@ -23,10 +29,11 @@ def __init__( self, on_close: Optional[VoidCallback], geometry: Optional[DialogGeometry] = None, + tag: str = TAG, ) -> None: self._on_close = on_close super().__init__( - tag=TAG, + tag=tag, geometry=geometry if geometry is not None else DialogGeometry(width=STATED_WIDTH, height=STATED_HEIGHT), ) @@ -41,6 +48,15 @@ def create_window(self) -> None: dpg.add_combo(items=["a", "b"], width=-1) +class ReportingWindow(ProbeWindow): + """A window reporting work under way, which leaves the rest of the interface live beside it.""" + + _claims_the_screen = False + + def __init__(self) -> None: + super().__init__(on_close=None, tag=REPORT_TAG) + + @pytest.fixture(name="dpg_context") def dpg_context_fixture() -> Iterator[None]: dpg.create_context() @@ -146,50 +162,150 @@ def test_a_dialog_answering_for_no_close_omits_the_button(self, dpg_context: Non class TestModalHandOff: """DearPyGui carries one modal at a time, so a dialog raising another has to step aside first.""" - def test_yielding_takes_the_window_off_screen(self, dpg_context: None) -> None: - window = ProbeWindow(on_close=None) - window.create_window() + @pytest.fixture(name="window") + def window_fixture(self, dpg_context: None) -> Iterator[ProbeWindow]: + """A dialog raised the way every dialog is, holding the screen.""" + with ( + patch(f"{MODULE}.ThemeRegistry"), + patch(f"{MODULE}.center_when_settled"), + patch.object(dpg, "get_viewport_client_width", return_value=VIEWPORT_WIDTH), + patch.object(dpg, "get_viewport_client_height", return_value=VIEWPORT_HEIGHT), + ): + window = ProbeWindow(on_close=None) + window.show() + yield window - with patch(f"{MODULE}.FrameCallbackManager"): - window.yield_to(MagicMock()) + def test_yielding_takes_the_window_off_screen(self, window: ProbeWindow, held_frames: Frames) -> None: + window.yield_to(MagicMock()) assert dpg.get_item_configuration(TAG)["show"] is False - def test_the_modal_is_raised_a_frame_after_the_hand_off(self, dpg_context: None) -> None: + def test_the_modal_is_raised_a_frame_after_the_hand_off(self, window: ProbeWindow, held_frames: Frames) -> None: """A modal built while this window still holds the screen opens where nobody can reach it.""" - window = ProbeWindow(on_close=None) - window.create_window() raise_modal = MagicMock() - with patch(f"{MODULE}.FrameCallbackManager") as frame: - window.yield_to(raise_modal) - + window.yield_to(raise_modal) raise_modal.assert_not_called() - frame.set_frame_callback.assert_called_once_with(raise_modal) + held_frames.render() - def test_resuming_waits_a_frame_before_taking_the_screen_back(self, dpg_context: None) -> None: - window = ProbeWindow(on_close=None) - window.create_window() - with patch(f"{MODULE}.FrameCallbackManager"): - window.yield_to(MagicMock()) + raise_modal.assert_called_once_with() - with patch(f"{MODULE}.FrameCallbackManager") as frame: - window.resume() + def test_resuming_waits_a_frame_before_taking_the_screen_back( + self, + window: ProbeWindow, + held_frames: Frames, + ) -> None: + window.yield_to(MagicMock()) + held_frames.render() + window.resume() assert dpg.get_item_configuration(TAG)["show"] is False - frame.set_frame_callback.assert_called_once() - frame.set_frame_callback.call_args.args[0]() + held_frames.render() + assert dpg.get_item_configuration(TAG)["show"] is True - def test_the_widget_tree_survives_the_hand_off(self, dpg_context: None) -> None: + def test_the_widget_tree_survives_the_hand_off(self, window: ProbeWindow, held_frames: Frames) -> None: """Whatever is being edited has to still be there when the dialog comes back.""" + window.yield_to(MagicMock()) + + assert dpg.get_item_children(TAG, 1) + + def test_leaving_deletes_the_window(self, window: ProbeWindow, held_frames: Frames) -> None: + window._leave_then(MagicMock()) + + assert not dpg.does_item_exist(TAG) + + def test_the_answer_runs_a_frame_after_the_window_left(self, window: ProbeWindow, held_frames: Frames) -> None: + answer = MagicMock() + + window._leave_then(answer) + answer.assert_not_called() + held_frames.render() + + answer.assert_called_once_with() + + def test_a_window_that_already_left_answers_nothing(self, window: ProbeWindow, held_frames: Frames) -> None: + """A second click reaches a window gone from the screen, and only the first one answers.""" + first = MagicMock() + second = MagicMock() + + window._leave_then(first) + window._leave_then(second) + held_frames.render() + + first.assert_called_once_with() + second.assert_not_called() + + +class TestOneModalAtATime: + """A modal asked for while another conversation holds the screen opens once that one has ended.""" + + @pytest.fixture(name="viewport", autouse=True) + def viewport_fixture(self) -> Iterator[None]: + with ( + patch(f"{MODULE}.ThemeRegistry"), + patch(f"{MODULE}.center_when_settled"), + patch.object(dpg, "get_viewport_client_width", return_value=VIEWPORT_WIDTH), + patch.object(dpg, "get_viewport_client_height", return_value=VIEWPORT_HEIGHT), + ): + yield + + def test_a_window_asked_for_while_another_stands_waits_for_it_to_leave( + self, + dpg_context: None, + held_frames: Frames, + ) -> None: + standing = ProbeWindow(on_close=None) + waiting = ProbeWindow(on_close=None, tag=OTHER_TAG) + standing.show() + + waiting.show() + held_frames.render() + assert not dpg.does_item_exist(OTHER_TAG) + + standing.hide() + held_frames.render() + + assert dpg.does_item_exist(OTHER_TAG) + + def test_what_an_answer_raises_opens_ahead_of_a_waiting_window( + self, + dpg_context: None, + held_frames: Frames, + ) -> None: + standing = ProbeWindow(on_close=None) + waiting = ProbeWindow(on_close=None, tag=OTHER_TAG) + prompt = ProbeWindow(on_close=None, tag=PROMPT_TAG) + standing.show() + waiting.show() + + standing._leave_then(prompt.show) + held_frames.render() + + assert dpg.does_item_exist(PROMPT_TAG) + assert not dpg.does_item_exist(OTHER_TAG) + + def test_a_window_reporting_work_under_way_opens_beside_a_modal(self, dpg_context: None) -> None: + """A window leaving the rest of the interface live is not a modal, so it waits for nothing.""" + ProbeWindow(on_close=None).show() + + ReportingWindow().show() + + assert dpg.does_item_exist(REPORT_TAG) + + def test_showing_a_standing_window_again_rebuilds_it_a_frame_later( + self, + dpg_context: None, + held_frames: Frames, + ) -> None: window = ProbeWindow(on_close=None) - window.create_window() + window.show() - with patch(f"{MODULE}.FrameCallbackManager"): - window.yield_to(MagicMock()) + window.show() + assert not dpg.does_item_exist(TAG) + held_frames.render() - assert dpg.get_item_children(TAG, 1) + assert dpg.does_item_exist(TAG) class TestRaisingAWindow: diff --git a/tests/unit/sampletones_application/ui/panels/dialogs/conftest.py b/tests/unit/sampletones_application/ui/panels/dialogs/conftest.py index 064cbda5c..053072347 100644 --- a/tests/unit/sampletones_application/ui/panels/dialogs/conftest.py +++ b/tests/unit/sampletones_application/ui/panels/dialogs/conftest.py @@ -17,6 +17,9 @@ from sampletones_application.ui.themes.setup import setup_themes from sampletones_application.utils.palette.catalog import PaletteCatalog from sampletones_application.utils.palette.source import PaletteSource +from tests.suite.frames import held_frames + +__all__ = ["held_frames"] @pytest.fixture diff --git a/tests/unit/sampletones_application/ui/panels/dialogs/test_audio_settings.py b/tests/unit/sampletones_application/ui/panels/dialogs/test_audio_settings.py new file mode 100644 index 000000000..9f05e0e92 --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/dialogs/test_audio_settings.py @@ -0,0 +1,87 @@ +from typing import Final, List, Tuple + +import dearpygui.dearpygui as dpg +import pytest + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.layout.config import LayoutConfig +from sampletones_application.paths import LANG_EN +from sampletones_application.tags.settings import TAG_SETTINGS_AUDIO_WINDOW +from sampletones_application.ui.panels.dialogs.audio_settings import GUIAudioSettingsWindow +from sampletones_application.utils.gui.keyboard import KeyRouter +from sampletones_application.view_model.shared.audio_settings import ( + AudioDeviceItem, + AudioSettingsViewModel, +) +from sampletones_core.constants.audio import DEFAULT_BUFFER_SIZE, DEFAULT_SAMPLE_RATE, BufferSize, SampleRate +from sampletones_shared.constants.audio import UNITY_GAIN +from tests.suite.frames import Frames +from tests.suite.shortcuts import shipped_source + +LANGUAGE_MANAGER: Final[LanguageManager] = LanguageManager(LANG_EN) +DEVICE_INDEX: Final[int] = 3 + +Committed = Tuple[int, SampleRate, BufferSize] + + +@pytest.fixture(name="window") +def window_fixture(dpg_context: None, layout_config: LayoutConfig) -> GUIAudioSettingsWindow: + return GUIAudioSettingsWindow( + layout=layout_config.settings, + language_manager=LANGUAGE_MANAGER, + key_router=KeyRouter(), + shortcut_source=shipped_source(), + ) + + +@pytest.fixture(name="committed") +def committed_fixture(window: GUIAudioSettingsWindow) -> List[Committed]: + """The settings the owner heard, from a window built the way ``open`` builds it.""" + committed: List[Committed] = [] + window.on_commit = lambda device_index, sample_rate, buffer_size: committed.append( + (device_index, sample_rate, buffer_size) + ) + window._seed( + AudioSettingsViewModel( + devices=( + AudioDeviceItem( + device_index=DEVICE_INDEX, + name="Speakers", + sample_rates=(DEFAULT_SAMPLE_RATE,), + default_sample_rate=DEFAULT_SAMPLE_RATE, + ), + ), + current_device_index=DEVICE_INDEX, + current_sample_rate=DEFAULT_SAMPLE_RATE, + buffer_size=DEFAULT_BUFFER_SIZE, + master_gain=UNITY_GAIN, + ) + ) + window.create_window() + return committed + + +class TestApplying: + """Applying can raise a playback error, which opens alone once the settings have left.""" + + def test_the_window_leaves_before_the_owner_hears_it( + self, + window: GUIAudioSettingsWindow, + committed: List[Committed], + held_frames: Frames, + ) -> None: + window._commit() + + assert not dpg.does_item_exist(TAG_SETTINGS_AUDIO_WINDOW) + assert committed == [] + + def test_the_owner_hears_what_the_window_held( + self, + window: GUIAudioSettingsWindow, + committed: List[Committed], + held_frames: Frames, + ) -> None: + window._commit() + held_frames.render() + + assert committed == [(DEVICE_INDEX, DEFAULT_SAMPLE_RATE, DEFAULT_BUFFER_SIZE)] diff --git a/tests/unit/sampletones_application/ui/panels/dialogs/test_project_properties.py b/tests/unit/sampletones_application/ui/panels/dialogs/test_project_properties.py index 0fb034636..8ea9a41f4 100644 --- a/tests/unit/sampletones_application/ui/panels/dialogs/test_project_properties.py +++ b/tests/unit/sampletones_application/ui/panels/dialogs/test_project_properties.py @@ -15,6 +15,7 @@ TAG_SETTINGS_PROPERTIES_INPUT_FIRST_HIGHLIGHT, TAG_SETTINGS_PROPERTIES_INPUT_SECOND_HIGHLIGHT, TAG_SETTINGS_PROPERTIES_INPUT_TITLE, + TAG_SETTINGS_PROPERTIES_WINDOW, ) from sampletones_application.ui.panels.dialogs.project_properties import ( GUIProjectPropertiesWindow, @@ -24,6 +25,7 @@ ProjectPropertiesViewModel, ) from sampletones_shared.constants.project import MAX_HIGHLIGHT, MIN_HIGHLIGHT +from tests.suite.frames import Frames from tests.suite.shortcuts import shipped_source LANGUAGE_MANAGER: Final[LanguageManager] = LanguageManager(LANG_EN) @@ -113,7 +115,10 @@ def test_both_actions_are_offered(self, window: GUIProjectPropertiesWindow) -> N class TestCommit: - """Confirming reports the whole form at once, so the owner applies one undoable gesture.""" + """Confirming reports the whole form at once, so the owner applies one undoable gesture. + + The form leaves the screen before the owner hears it, so the values are read as it goes. + """ @pytest.fixture(name="committed") def committed_fixture(self, window: GUIProjectPropertiesWindow) -> List[Committed]: @@ -128,11 +133,13 @@ def test_the_edited_meter_reaches_the_owner( self, window: GUIProjectPropertiesWindow, committed: List[Committed], + held_frames: Frames, ) -> None: dpg.set_value(TAG_SETTINGS_PROPERTIES_INPUT_FIRST_HIGHLIGHT, 3) dpg.set_value(TAG_SETTINGS_PROPERTIES_INPUT_SECOND_HIGHLIGHT, 9) window._commit() + held_frames.render() assert committed == [("Chiptune", "Composer", "A note to self", 3, 9)] @@ -140,12 +147,14 @@ def test_a_highlight_past_the_range_arrives_clamped( self, window: GUIProjectPropertiesWindow, committed: List[Committed], + held_frames: Frames, ) -> None: """The project rejects a highlight outside its bounds, so the dialog reports one inside.""" dpg.set_value(TAG_SETTINGS_PROPERTIES_INPUT_FIRST_HIGHLIGHT, MAX_HIGHLIGHT + 1) dpg.set_value(TAG_SETTINGS_PROPERTIES_INPUT_SECOND_HIGHLIGHT, MIN_HIGHLIGHT - 1) window._commit() + held_frames.render() assert committed[-1][3:] == (MAX_HIGHLIGHT, MIN_HIGHLIGHT) @@ -153,10 +162,12 @@ def test_the_meter_carries_the_info_with_it( self, window: GUIProjectPropertiesWindow, committed: List[Committed], + held_frames: Frames, ) -> None: dpg.set_value(TAG_SETTINGS_PROPERTIES_INPUT_TITLE, "Another song") window._commit() + held_frames.render() assert committed[-1] == ( "Another song", @@ -165,3 +176,14 @@ def test_the_meter_carries_the_info_with_it( FIRST_HIGHLIGHT, SECOND_HIGHLIGHT, ) + + def test_the_form_leaves_before_the_owner_hears_it( + self, + window: GUIProjectPropertiesWindow, + committed: List[Committed], + held_frames: Frames, + ) -> None: + window._commit() + + assert not dpg.does_item_exist(TAG_SETTINGS_PROPERTIES_WINDOW) + assert committed == [] diff --git a/tests/unit/sampletones_application/ui/panels/dialogs/test_stem_selection.py b/tests/unit/sampletones_application/ui/panels/dialogs/test_stem_selection.py index cbb9e7a39..ba1701bbe 100644 --- a/tests/unit/sampletones_application/ui/panels/dialogs/test_stem_selection.py +++ b/tests/unit/sampletones_application/ui/panels/dialogs/test_stem_selection.py @@ -15,6 +15,7 @@ PRE_MAIN_CONVERTER_CANDIDATE, TAG_MAIN_CONVERTER_BUTTON_ADD_STEMS, TAG_MAIN_CONVERTER_TEXT_STEM_SELECTION_LIMIT, + TAG_MAIN_CONVERTER_WINDOW_STEM_SELECTION, ) from sampletones_application.ui.elements.status import GUIStatusBar from sampletones_application.ui.elements.stems.tags import StemsTags @@ -23,6 +24,7 @@ from sampletones_application.view_model.shared.stems import StemRowViewModel from sampletones_core.constants.enums import ChannelName from tests.suite.base import BaseTestSuite +from tests.suite.frames import Frames from tests.suite.gestures import DOUBLE_CLICKED, click_row_name from tests.suite.shortcuts import shipped_source @@ -137,6 +139,12 @@ def click_name(row: StemRowViewModel, value: bool) -> None: dpg.get_item_callback(name_tag)(name_tag, value, row.key) +def add(held_frames: Frames) -> None: + """Presses Add and lets the frame go by that carries the answer, once the question has left.""" + dpg.get_item_callback(compose_tag(TAG_MAIN_CONVERTER_BUTTON_ADD_STEMS, SUF_BUTTON))() + held_frames.render() + + def add_enabled() -> bool: return bool(dpg.get_item_configuration(compose_tag(TAG_MAIN_CONVERTER_BUTTON_ADD_STEMS, SUF_BUTTON))["enabled"]) @@ -178,33 +186,59 @@ def test_the_ones_it_holds_still_answer(self, window: GUIStemSelectionWindow) -> class TestSettlingTheMix(BaseTestSuite): """The mix is settled once the pick fits, and the line above says where the pick stands.""" - def test_a_pick_that_fits_settles(self, window: GUIStemSelectionWindow) -> None: + def test_a_pick_that_fits_settles( + self, + window: GUIStemSelectionWindow, + held_frames: Frames, + ) -> None: offered = candidates() answered: List[List[Path]] = [] render(window, offered, answered.append) - dpg.get_item_callback(compose_tag(TAG_MAIN_CONVERTER_BUTTON_ADD_STEMS, SUF_BUTTON))() + add(held_frames) assert answered == [paths()[:MAX_STEM_SOURCES]] - def test_swapping_one_for_another_keeps_it_settling(self, window: GUIStemSelectionWindow) -> None: + def test_the_question_leaves_before_the_mix_hears_the_answer( + self, + window: GUIStemSelectionWindow, + held_frames: Frames, + ) -> None: + """What the answer opens next, such as an error, stands alone once the question has gone.""" + answered: List[List[Path]] = [] + render(window, candidates(), answered.append) + + dpg.get_item_callback(compose_tag(TAG_MAIN_CONVERTER_BUTTON_ADD_STEMS, SUF_BUTTON))() + + assert not dpg.does_item_exist(TAG_MAIN_CONVERTER_WINDOW_STEM_SELECTION) + assert answered == [] + + def test_swapping_one_for_another_keeps_it_settling( + self, + window: GUIStemSelectionWindow, + held_frames: Frames, + ) -> None: offered = candidates() answered: List[List[Path]] = [] render(window, offered, answered.append) pick(offered[0]) pick(offered[MAX_STEM_SOURCES]) - dpg.get_item_callback(compose_tag(TAG_MAIN_CONVERTER_BUTTON_ADD_STEMS, SUF_BUTTON))() + add(held_frames) assert answered == [paths()[1 : MAX_STEM_SOURCES + 1]] - def test_a_pick_past_the_room_leaves_the_mix_as_it_was(self, window: GUIStemSelectionWindow) -> None: + def test_a_pick_past_the_room_leaves_the_mix_as_it_was( + self, + window: GUIStemSelectionWindow, + held_frames: Frames, + ) -> None: offered = candidates() answered: List[List[Path]] = [] render(window, offered, answered.append) pick(offered[MAX_STEM_SOURCES]) - dpg.get_item_callback(compose_tag(TAG_MAIN_CONVERTER_BUTTON_ADD_STEMS, SUF_BUTTON))() + add(held_frames) assert answered == [paths()[:MAX_STEM_SOURCES]] @@ -272,6 +306,7 @@ def test_one_click_lets_the_whole_folder_go(self, window: GUIStemSelectionWindow def test_a_folder_larger_than_the_room_takes_as_many_as_fit( self, window: GUIStemSelectionWindow, + held_frames: Frames, ) -> None: """A folder settles either way, so it lets go of a full mix and takes what fits again.""" answered: List[List[Path]] = [] @@ -286,41 +321,52 @@ def test_a_folder_larger_than_the_room_takes_as_many_as_fit( pick(folder) assert add_enabled() is True - dpg.get_item_callback(compose_tag(TAG_MAIN_CONVERTER_BUTTON_ADD_STEMS, SUF_BUTTON))() + add(held_frames) assert answered == [held[:MAX_STEM_SOURCES]] - def test_what_it_holds_is_what_the_mix_takes(self, window: GUIStemSelectionWindow) -> None: + def test_what_it_holds_is_what_the_mix_takes( + self, + window: GUIStemSelectionWindow, + held_frames: Frames, + ) -> None: held = paths(3) answered: List[List[Path]] = [] render(window, [folder_row(Path("/audio/takes"), held)], answered.append) - dpg.get_item_callback(compose_tag(TAG_MAIN_CONVERTER_BUTTON_ADD_STEMS, SUF_BUTTON))() + add(held_frames) assert answered == [held] class TestAskingTwice(BaseTestSuite): """The window takes its whole tree down between one opening and the next, so a second asking - draws the rows rather than repainting the ones that are gone.""" + draws the rows rather than repainting the ones that are gone. The frame the window left in still + draws it, so the second asking opens a frame later.""" - def test_the_rows_are_drawn_again(self, window: GUIStemSelectionWindow) -> None: + def test_the_rows_are_drawn_again(self, window: GUIStemSelectionWindow, held_frames: Frames) -> None: offered = candidates() render(window, offered) window.hide() render(window, offered) + held_frames.render() for row in offered: assert dpg.does_item_exist(box_of(row)) - def test_the_pick_still_settles(self, window: GUIStemSelectionWindow) -> None: + def test_the_pick_still_settles( + self, + window: GUIStemSelectionWindow, + held_frames: Frames, + ) -> None: offered = candidates() answered: List[List[Path]] = [] render(window, offered) window.hide() render(window, offered, answered.append) - dpg.get_item_callback(compose_tag(TAG_MAIN_CONVERTER_BUTTON_ADD_STEMS, SUF_BUTTON))() + held_frames.render() + add(held_frames) assert answered == [paths()[:MAX_STEM_SOURCES]] diff --git a/tests/unit/sampletones_application/utils/callbacks/test_gates.py b/tests/unit/sampletones_application/utils/callbacks/test_gates.py new file mode 100644 index 000000000..8ebd3bc08 --- /dev/null +++ b/tests/unit/sampletones_application/utils/callbacks/test_gates.py @@ -0,0 +1,87 @@ +from typing import List, Optional + +from sampletones_application.utils.callbacks.gates import pass_gates +from sampletones_shared.types.callback import VoidCallback + + +class Guard: + """A gate over one unfinished thing, recording when it is reached and holding its question.""" + + def __init__(self, name: str, reached: List[str], *, unfinished: bool) -> None: + self.name = name + self.unfinished = unfinished + self._reached = reached + self.question: Optional[VoidCallback] = None + + def __call__(self, proceed: VoidCallback) -> None: + self._reached.append(self.name) + if not self.unfinished: + proceed() + return + + self.question = proceed + + def answer(self) -> None: + """The reader going on past the question this gate asked.""" + assert self.question is not None + question, self.question = self.question, None + question() + + +class TestPassingGates: + def test_no_gate_arrives_at_once(self) -> None: + arrived: List[str] = [] + + pass_gates((), lambda: arrived.append("arrived")) + + assert arrived == ["arrived"] + + def test_open_gates_are_passed_in_order(self) -> None: + reached: List[str] = [] + gates = [Guard(name, reached, unfinished=False) for name in ("first", "second", "third")] + + pass_gates(gates, lambda: reached.append("arrived")) + + assert reached == ["first", "second", "third", "arrived"] + + def test_a_question_holds_the_gates_after_it(self) -> None: + reached: List[str] = [] + asking = Guard("first", reached, unfinished=True) + + pass_gates((asking, Guard("second", reached, unfinished=False)), lambda: reached.append("arrived")) + + assert reached == ["first"] + + def test_an_answer_goes_on_to_the_next_gate(self) -> None: + reached: List[str] = [] + asking = Guard("first", reached, unfinished=True) + pass_gates((asking, Guard("second", reached, unfinished=False)), lambda: reached.append("arrived")) + + asking.answer() + + assert reached == ["first", "second", "arrived"] + + def test_a_declined_question_arrives_nowhere(self) -> None: + """Declining answers nothing, so the gates after it and the arrival wait for good.""" + reached: List[str] = [] + first = Guard("first", reached, unfinished=True) + second = Guard("second", reached, unfinished=True) + third = Guard("third", reached, unfinished=False) + pass_gates((first, second, third), lambda: reached.append("arrived")) + + first.answer() + + assert reached == ["first", "second"] + + def test_a_gate_reads_what_it_guards_when_it_is_reached(self) -> None: + """A state an earlier question saw settled meanwhile asks nothing once its gate is reached.""" + reached: List[str] = [] + asking = Guard("first", reached, unfinished=True) + later = Guard("second", reached, unfinished=True) + pass_gates((asking, later), lambda: reached.append("arrived")) + + later.unfinished = False + asking.answer() + + assert reached == ["first", "second", "arrived"] + assert later.question is None diff --git a/tests/unit/sampletones_application/utils/gui/dialog_navigation/test_navigator.py b/tests/unit/sampletones_application/utils/gui/dialog_navigation/test_navigator.py index a936841cb..67a68f371 100644 --- a/tests/unit/sampletones_application/utils/gui/dialog_navigation/test_navigator.py +++ b/tests/unit/sampletones_application/utils/gui/dialog_navigation/test_navigator.py @@ -128,6 +128,18 @@ def test_dispose_releases_the_keyboard_once(self) -> None: assert router.is_modal_open + def test_a_navigator_disposed_beneath_a_newer_claim_releases_its_own(self) -> None: + """A dialog closing after it raised another leaves the keyboard with the one it raised.""" + router = KeyRouter() + navigator = _navigator(on_escape=MagicMock(), router=router) + router.push_modal(navigator) + raised = MagicMock() + router.push_modal(raised) + + navigator.dispose() + + assert router._modal_stack == [raised] + def test_focus_initial_delegates_while_the_window_is_present(self) -> None: navigator = _navigator(on_escape=MagicMock(), router=KeyRouter()) navigator._ring = MagicMock() diff --git a/tests/unit/sampletones_application/utils/gui/dialogs/windows/conftest.py b/tests/unit/sampletones_application/utils/gui/dialogs/windows/conftest.py index 514f516d7..7e6992a1f 100644 --- a/tests/unit/sampletones_application/utils/gui/dialogs/windows/conftest.py +++ b/tests/unit/sampletones_application/utils/gui/dialogs/windows/conftest.py @@ -16,6 +16,9 @@ from sampletones_application.ui.themes.setup import setup_themes from sampletones_application.utils.palette.catalog import PaletteCatalog from sampletones_application.utils.palette.source import PaletteSource +from tests.suite.frames import held_frames + +__all__ = ["held_frames"] @pytest.fixture diff --git a/tests/unit/sampletones_application/utils/gui/dialogs/windows/test_confirmation.py b/tests/unit/sampletones_application/utils/gui/dialogs/windows/test_confirmation.py index d468baf79..6976801a8 100644 --- a/tests/unit/sampletones_application/utils/gui/dialogs/windows/test_confirmation.py +++ b/tests/unit/sampletones_application/utils/gui/dialogs/windows/test_confirmation.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Final, List +from typing import Final, List, Optional from unittest.mock import MagicMock import dearpygui.dearpygui as dpg @@ -12,93 +12,305 @@ SUF_BUTTON_CANCEL, SUF_BUTTON_OK, SUF_CHECKBOX, + TAG_GLOBAL_DIALOG_EXIT_CONFIRMATION, TAG_GLOBAL_DIALOG_PATH_MESSAGE, ) from sampletones_application.utils.gui.dialogs import get_dialog_tag from sampletones_application.utils.gui.dialogs.windows.confirmation import ( GUIConfirmationWindow, ) -from sampletones_application.utils.gui.keyboard import KeyRouter -from tests.suite.shortcuts import shipped_source +from sampletones_application.utils.gui.keyboard import KeyEvent, KeyRouter +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_shared.types.callback import VoidCallback +from tests.suite.frames import Frames +from tests.suite.shortcuts import shipped_scheme, shipped_source WINDOW_TAG: Final[str] = get_dialog_tag(TAG_GLOBAL_DIALOG_PATH_MESSAGE) +FOLLOWING_TAG: Final[str] = get_dialog_tag(TAG_GLOBAL_DIALOG_EXIT_CONFIRMATION) CONFIRMED: Final[str] = "confirmed" CANCELED: Final[str] = "canceled" OPTED_OUT: Final[str] = "opted_out" +FOLLOWING_CANCELED: Final[str] = "following_canceled" -@pytest.fixture(name="window") -def window_fixture(dpg_context: None, layout_config: LayoutConfig) -> GUIConfirmationWindow: +@pytest.fixture(name="router") +def router_fixture() -> KeyRouter: + return KeyRouter() + + +def build_window(tag: str, layout_config: LayoutConfig, router: KeyRouter) -> GUIConfirmationWindow: return GUIConfirmationWindow( - tag=WINDOW_TAG, + tag=tag, geometry=layout_config.general.dialogs.confirmation, wrap=layout_config.general.dialogs.default.width - 10, path_color=layout_config.general.colors.paths.default, path_hover_color=layout_config.general.colors.paths.hover, path_message="path", status_bar=MagicMock(), - key_router=KeyRouter(), + key_router=router, shortcut_source=shipped_source(), ) +@pytest.fixture(name="window") +def window_fixture( + dpg_context: None, + layout_config: LayoutConfig, + router: KeyRouter, +) -> GUIConfirmationWindow: + return build_window(WINDOW_TAG, layout_config, router) + + def render( window: GUIConfirmationWindow, *, - path: Path | None = None, - opt_out_label: str | None = None, - answers: List[str] | None = None, + answers: List[str], + path: Optional[Path] = None, + opt_out_label: Optional[str] = None, + on_confirm: Optional[VoidCallback] = None, ) -> None: """Builds the prompt for the given question, the way ``show`` does without a live frame.""" window.prepare( "Save it?", "Title", - lambda: answers.append(CONFIRMED) if answers is not None else None, + on_confirm if on_confirm is not None else lambda: answers.append(CONFIRMED), ok_label="Yes", cancel_label="No", path=path, opt_out_label=opt_out_label, - on_opt_out=lambda: answers.append(OPTED_OUT) if answers is not None else None, - on_cancel=lambda: answers.append(CANCELED) if answers is not None else None, + on_opt_out=lambda: answers.append(OPTED_OUT), + on_cancel=lambda: answers.append(CANCELED), ) window.create_window() -def press(tag: str) -> None: - dpg.get_item_callback(compose_tag(tag, SUF_BUTTON))() +def button_callback(window_tag: str, suffix: str) -> VoidCallback: + """What a click on one of the prompt's buttons runs, read while the button stands.""" + callback = dpg.get_item_callback(compose_tag(compose_tag(window_tag, suffix), SUF_BUTTON)) + assert callback is not None + return callback + + +def press(window_tag: str, suffix: str) -> None: + button_callback(window_tag, suffix)() + + +def escape(router: KeyRouter) -> None: + """The press the shipped scheme cancels a dialog with, routed the way the keyboard sends it.""" + combination = shipped_scheme().shortcut(ShortcutId.DIALOG_CANCEL).combination + assert combination is not None + router.route(KeyEvent(key=combination.key, modifiers=combination.modifiers)) + + +class TestAnAnswerRunsOnceThePromptHasLeft: + """DearPyGui carries one modal at a time, so whatever an answer raises waits for the prompt to go.""" + + def test_ok_leaves_the_screen_before_it_answers( + self, + window: GUIConfirmationWindow, + held_frames: Frames, + ) -> None: + answers: List[str] = [] + render(window, answers=answers) + + press(WINDOW_TAG, SUF_BUTTON_OK) + assert not dpg.does_item_exist(WINDOW_TAG) + assert answers == [] -class TestConfirmationWindow: - def test_ok_runs_the_confirmation_and_closes(self, window: GUIConfirmationWindow) -> None: + def test_ok_confirms_a_frame_later( + self, + window: GUIConfirmationWindow, + held_frames: Frames, + ) -> None: answers: List[str] = [] render(window, answers=answers) - press(compose_tag(WINDOW_TAG, SUF_BUTTON_OK)) + press(WINDOW_TAG, SUF_BUTTON_OK) + held_frames.render() assert answers == [CONFIRMED] + + def test_cancel_leaves_the_screen_before_it_answers( + self, + window: GUIConfirmationWindow, + held_frames: Frames, + ) -> None: + answers: List[str] = [] + render(window, answers=answers) + + press(WINDOW_TAG, SUF_BUTTON_CANCEL) + assert not dpg.does_item_exist(WINDOW_TAG) + assert answers == [] - def test_cancel_runs_the_negative_answer_and_closes(self, window: GUIConfirmationWindow) -> None: + def test_cancel_answers_the_negative_choice_a_frame_later( + self, + window: GUIConfirmationWindow, + held_frames: Frames, + ) -> None: answers: List[str] = [] render(window, answers=answers) - press(compose_tag(WINDOW_TAG, SUF_BUTTON_CANCEL)) + press(WINDOW_TAG, SUF_BUTTON_CANCEL) + held_frames.render() assert answers == [CANCELED] + + def test_escape_answers_as_cancel( + self, + window: GUIConfirmationWindow, + router: KeyRouter, + held_frames: Frames, + ) -> None: + answers: List[str] = [] + render(window, answers=answers) + + escape(router) + held_frames.render() + assert not dpg.does_item_exist(WINDOW_TAG) + assert answers == [CANCELED] - def test_a_ticked_opt_out_rides_the_confirmation(self, window: GUIConfirmationWindow) -> None: + def test_the_title_bar_close_answers_as_cancel( + self, + window: GUIConfirmationWindow, + held_frames: Frames, + ) -> None: answers: List[str] = [] - render(window, opt_out_label="Do not ask again", answers=answers) + render(window, answers=answers) + + dpg.get_item_configuration(WINDOW_TAG)["on_close"]() + held_frames.render() + + assert not dpg.does_item_exist(WINDOW_TAG) + assert answers == [CANCELED] + + def test_a_ticked_opt_out_rides_the_confirmation( + self, + window: GUIConfirmationWindow, + held_frames: Frames, + ) -> None: + """The box is read as the reader leaves, since the answer runs once it has been deleted.""" + answers: List[str] = [] + render(window, answers=answers, opt_out_label="Do not ask again") dpg.set_value(compose_tag(WINDOW_TAG, SUF_CHECKBOX), True) - press(compose_tag(WINDOW_TAG, SUF_BUTTON_OK)) + press(WINDOW_TAG, SUF_BUTTON_OK) + held_frames.render() + assert not dpg.does_item_exist(compose_tag(WINDOW_TAG, SUF_CHECKBOX)) assert answers == [OPTED_OUT, CONFIRMED] - def test_the_path_is_shown_when_given(self, window: GUIConfirmationWindow, tmp_path: Path) -> None: - path = tmp_path / "song.stn" + def test_a_clear_opt_out_confirms_alone( + self, + window: GUIConfirmationWindow, + held_frames: Frames, + ) -> None: + answers: List[str] = [] + render(window, answers=answers, opt_out_label="Do not ask again") + + press(WINDOW_TAG, SUF_BUTTON_OK) + held_frames.render() + + assert answers == [CONFIRMED] + + def test_the_keyboard_is_free_when_the_answer_runs( + self, + window: GUIConfirmationWindow, + router: KeyRouter, + held_frames: Frames, + ) -> None: + """A prompt the answer raises claims the keyboard on its own, over whatever stood beneath.""" + claims: List[bool] = [] + render(window, answers=[], on_confirm=lambda: claims.append(router.is_modal_open)) + + press(WINDOW_TAG, SUF_BUTTON_OK) + held_frames.render() + + assert claims == [False] + + def test_a_second_press_answers_nothing( + self, + window: GUIConfirmationWindow, + held_frames: Frames, + ) -> None: + """A click DearPyGui delivers after the first one reaches a prompt that has already left.""" + answers: List[str] = [] + render(window, answers=answers) + confirm = button_callback(WINDOW_TAG, SUF_BUTTON_OK) + cancel = button_callback(WINDOW_TAG, SUF_BUTTON_CANCEL) - render(window, path=path) + confirm() + confirm() + cancel() + held_frames.render() + + assert answers == [CONFIRMED] + assert held_frames.pending == 0 + + def test_the_path_is_shown_when_given( + self, + window: GUIConfirmationWindow, + tmp_path: Path, + ) -> None: + render(window, answers=[], path=tmp_path / "song.stn") assert dpg.does_item_exist(compose_tag(WINDOW_TAG, "path")) + + +class TestAPromptRaisedFromAnAnswer: + """An answer asking a question of its own — "Load it now?" leading to "Save your changes?" — + raises a second prompt, which stands alone and answers the keyboard.""" + + @pytest.fixture(name="answers") + def answers_fixture( + self, + window: GUIConfirmationWindow, + layout_config: LayoutConfig, + router: KeyRouter, + held_frames: Frames, + ) -> List[str]: + answers: List[str] = [] + following = build_window(FOLLOWING_TAG, layout_config, router) + + def ask_again() -> None: + answers.append(f"first_standing={dpg.does_item_exist(WINDOW_TAG)}") + following.prepare( + "Save your changes?", + "Title", + lambda: answers.append(CONFIRMED), + ok_label="Save", + cancel_label="Cancel", + path=None, + opt_out_label=None, + on_opt_out=None, + on_cancel=lambda: answers.append(FOLLOWING_CANCELED), + ) + following.create_window() + + render(window, answers=answers, on_confirm=ask_again) + press(WINDOW_TAG, SUF_BUTTON_OK) + held_frames.render() + return answers + + def test_the_first_prompt_is_gone_when_the_second_is_built(self, answers: List[str]) -> None: + assert answers == ["first_standing=False"] + + def test_the_second_prompt_stands(self, answers: List[str]) -> None: + assert dpg.does_item_exist(FOLLOWING_TAG) + + def test_the_second_prompt_holds_the_keyboard(self, answers: List[str], router: KeyRouter) -> None: + assert router.is_modal_open + + def test_escape_reaches_the_second_prompt( + self, + answers: List[str], + router: KeyRouter, + held_frames: Frames, + ) -> None: + escape(router) + held_frames.render() + + assert answers[1:] == [FOLLOWING_CANCELED] + assert not dpg.does_item_exist(FOLLOWING_TAG) diff --git a/tests/unit/sampletones_application/utils/gui/dialogs/windows/test_save_confirmation.py b/tests/unit/sampletones_application/utils/gui/dialogs/windows/test_save_confirmation.py index c6ae7b588..5e3185802 100644 --- a/tests/unit/sampletones_application/utils/gui/dialogs/windows/test_save_confirmation.py +++ b/tests/unit/sampletones_application/utils/gui/dialogs/windows/test_save_confirmation.py @@ -1,4 +1,5 @@ -from typing import Final, List +from typing import Final, Iterator, List +from unittest.mock import patch import dearpygui.dearpygui as dpg import pytest @@ -13,25 +14,39 @@ TAG_GLOBAL_DIALOG_FILE_NOT_FOUND, ) from sampletones_application.utils.gui.dialogs import get_dialog_tag +from sampletones_application.utils.gui.dialogs.outcome import SaveOutcome from sampletones_application.utils.gui.dialogs.windows.save_confirmation import ( GUISaveConfirmationWindow, ) from sampletones_application.utils.gui.keyboard import KeyRouter +from sampletones_shared.types.callback import VoidCallback +from tests.suite.frames import Frames from tests.suite.shortcuts import shipped_source WINDOW_TAG: Final[str] = get_dialog_tag(TAG_GLOBAL_DIALOG_FILE_NOT_FOUND) +MESSAGE: Final[str] = "Save first?" +SAVED: Final[str] = "saved" CONFIRMED: Final[str] = "confirmed" +@pytest.fixture(name="router") +def router_fixture() -> KeyRouter: + return KeyRouter() + + @pytest.fixture(name="window") -def window_fixture(dpg_context: None, layout_config: LayoutConfig) -> GUISaveConfirmationWindow: +def window_fixture( + dpg_context: None, + layout_config: LayoutConfig, + router: KeyRouter, +) -> GUISaveConfirmationWindow: return GUISaveConfirmationWindow( tag=WINDOW_TAG, geometry=layout_config.general.dialogs.confirmation, wrap=layout_config.general.dialogs.default.width - 10, save_label="Save", cancel_label="Cancel", - key_router=KeyRouter(), + key_router=router, shortcut_source=shipped_source(), ) @@ -39,57 +54,182 @@ def window_fixture(dpg_context: None, layout_config: LayoutConfig) -> GUISaveCon def render( window: GUISaveConfirmationWindow, *, - save_succeeds: bool, + save_outcome: SaveOutcome, answers: List[str], ) -> None: """Builds the prompt for the given save, the way ``show`` does without a live frame.""" + + def save() -> SaveOutcome: + answers.append(SAVED) + return save_outcome + window.prepare( - "Save first?", + MESSAGE, "Title", - lambda: save_succeeds, + save, lambda: answers.append(CONFIRMED), ok_label="Proceed", ) window.create_window() -def press(tag: str) -> None: - dpg.get_item_callback(compose_tag(tag, SUF_BUTTON))() +def button_callback(suffix: str) -> VoidCallback: + callback = dpg.get_item_callback(compose_tag(compose_tag(WINDOW_TAG, suffix), SUF_BUTTON)) + assert callback is not None + return callback + + +def press(suffix: str) -> None: + button_callback(suffix)() + + +@pytest.fixture(name="placed") +def placed_fixture(layout_config: LayoutConfig) -> Iterator[None]: + """Stands in for the viewport a prompt raised again is centered against, which a suite draws none of.""" + window = layout_config.general.window + with ( + patch.object(dpg, "get_viewport_client_width", return_value=window.width), + patch.object(dpg, "get_viewport_client_height", return_value=window.height), + ): + yield -class TestSaveConfirmationWindow: - def test_a_canceled_save_keeps_the_prompt_open(self, window: GUISaveConfirmationWindow) -> None: +class TestSavingFromThePrompt: + """Save writes the document once the prompt has left, so whatever the save opens stands alone.""" + + def test_save_leaves_the_screen_before_it_writes( + self, + window: GUISaveConfirmationWindow, + held_frames: Frames, + ) -> None: answers: List[str] = [] - render(window, save_succeeds=False, answers=answers) + render(window, save_outcome=SaveOutcome.WRITTEN, answers=answers) - press(compose_tag(WINDOW_TAG, SUF_BUTTON_SAVE)) + press(SUF_BUTTON_SAVE) + assert not dpg.does_item_exist(WINDOW_TAG) assert answers == [] - assert dpg.does_item_exist(WINDOW_TAG) - def test_a_completed_save_proceeds_and_closes(self, window: GUISaveConfirmationWindow) -> None: + def test_a_written_document_goes_on( + self, + window: GUISaveConfirmationWindow, + held_frames: Frames, + ) -> None: answers: List[str] = [] - render(window, save_succeeds=True, answers=answers) + render(window, save_outcome=SaveOutcome.WRITTEN, answers=answers) - press(compose_tag(WINDOW_TAG, SUF_BUTTON_SAVE)) + press(SUF_BUTTON_SAVE) + held_frames.render() - assert answers == [CONFIRMED] + assert answers == [SAVED, CONFIRMED] assert not dpg.does_item_exist(WINDOW_TAG) - def test_the_middle_button_proceeds_without_saving(self, window: GUISaveConfirmationWindow) -> None: + def test_a_save_called_off_brings_the_prompt_back( + self, + window: GUISaveConfirmationWindow, + held_frames: Frames, + placed: None, + ) -> None: + """A reader who closes the file dialog without a name is asked the same question again.""" answers: List[str] = [] - render(window, save_succeeds=False, answers=answers) + render(window, save_outcome=SaveOutcome.CALLED_OFF, answers=answers) - press(compose_tag(WINDOW_TAG, SUF_BUTTON_OK)) + press(SUF_BUTTON_SAVE) + held_frames.render() - assert answers == [CONFIRMED] + assert answers == [SAVED] + assert dpg.does_item_exist(WINDOW_TAG) + assert dpg.get_item_label(WINDOW_TAG) == "Title" + + def test_the_prompt_back_answers_as_it_did( + self, + window: GUISaveConfirmationWindow, + held_frames: Frames, + placed: None, + ) -> None: + answers: List[str] = [] + render(window, save_outcome=SaveOutcome.CALLED_OFF, answers=answers) + press(SUF_BUTTON_SAVE) + held_frames.render() + + press(SUF_BUTTON_OK) + held_frames.render() + + assert answers == [SAVED, CONFIRMED] + + def test_the_prompt_back_holds_the_keyboard_once( + self, + window: GUISaveConfirmationWindow, + router: KeyRouter, + held_frames: Frames, + placed: None, + ) -> None: + render(window, save_outcome=SaveOutcome.CALLED_OFF, answers=[]) + press(SUF_BUTTON_SAVE) + held_frames.render() + + assert len(router._modal_stack) == 1 + + def test_a_failed_save_leaves_the_error_alone_on_screen( + self, + window: GUISaveConfirmationWindow, + held_frames: Frames, + ) -> None: + answers: List[str] = [] + render(window, save_outcome=SaveOutcome.FAILED, answers=answers) + + press(SUF_BUTTON_SAVE) + held_frames.render() + + assert answers == [SAVED] assert not dpg.does_item_exist(WINDOW_TAG) + assert held_frames.pending == 0 + - def test_cancel_dismisses_the_prompt(self, window: GUISaveConfirmationWindow) -> None: +class TestTheOtherAnswers: + def test_the_middle_button_goes_on_once_the_prompt_has_left( + self, + window: GUISaveConfirmationWindow, + held_frames: Frames, + ) -> None: answers: List[str] = [] - render(window, save_succeeds=False, answers=answers) + render(window, save_outcome=SaveOutcome.WRITTEN, answers=answers) - press(compose_tag(WINDOW_TAG, SUF_BUTTON_CANCEL)) + press(SUF_BUTTON_OK) + + assert not dpg.does_item_exist(WINDOW_TAG) + assert answers == [] + held_frames.render() + assert answers == [CONFIRMED] + + def test_cancel_leaves_and_answers_nothing( + self, + window: GUISaveConfirmationWindow, + router: KeyRouter, + held_frames: Frames, + ) -> None: + answers: List[str] = [] + render(window, save_outcome=SaveOutcome.WRITTEN, answers=answers) + + press(SUF_BUTTON_CANCEL) + held_frames.render() assert answers == [] assert not dpg.does_item_exist(WINDOW_TAG) + assert not router.is_modal_open + + def test_a_second_press_answers_nothing( + self, + window: GUISaveConfirmationWindow, + held_frames: Frames, + ) -> None: + answers: List[str] = [] + render(window, save_outcome=SaveOutcome.WRITTEN, answers=answers) + save = button_callback(SUF_BUTTON_SAVE) + discard = button_callback(SUF_BUTTON_OK) + + save() + discard() + held_frames.render() + + assert answers == [SAVED, CONFIRMED] diff --git a/tests/unit/sampletones_application/utils/gui/keyboard/test_capture.py b/tests/unit/sampletones_application/utils/gui/keyboard/test_capture.py index d0b96015e..35ff15293 100644 --- a/tests/unit/sampletones_application/utils/gui/keyboard/test_capture.py +++ b/tests/unit/sampletones_application/utils/gui/keyboard/test_capture.py @@ -53,6 +53,16 @@ def _on_canceled(self) -> None: self.canceled += 1 +class Dialog: + """A dialog holding the keyboard beside the capture, recording the keys it is sent.""" + + def __init__(self) -> None: + self.keys: List[int] = [] + + def handle_key(self, event: KeyEvent) -> None: + self.keys.append(event.key) + + @pytest.fixture(name="harness") def harness_fixture() -> Harness: harness = Harness() @@ -77,13 +87,31 @@ def test_starting_twice_claims_the_keyboard_once(self, harness: Harness) -> None assert not harness.router.is_modal_open - def test_stopping_twice_releases_the_claim_once(self, harness: Harness) -> None: + def test_stopping_twice_releases_the_claim_once(self) -> None: """A second release would drop the claim of the dialog the capture sits above.""" - harness.router.push_modal(harness.capture) + harness = Harness() + dialog = Dialog() + harness.router.push_modal(dialog) + harness.capture.start() + harness.capture.stop() harness.capture.stop() + harness.press(dpg.mvKey_F5) - assert harness.router.is_modal_open + assert dialog.keys == [dpg.mvKey_F5] + + def test_stopping_gives_back_its_own_claim_alone(self) -> None: + """A dialog raised over a listening capture keeps the keyboard when the capture stops.""" + harness = Harness() + harness.capture.start() + dialog = Dialog() + harness.router.push_modal(dialog) + + harness.capture.stop() + harness.press(dpg.mvKey_F5) + + assert dialog.keys == [dpg.mvKey_F5] + assert harness.captured == [] class TestCapturedPress(BaseTestSuite): diff --git a/tests/unit/sampletones_application/utils/gui/keyboard/test_router.py b/tests/unit/sampletones_application/utils/gui/keyboard/test_router.py index 74e0ab336..3d9cddc0e 100644 --- a/tests/unit/sampletones_application/utils/gui/keyboard/test_router.py +++ b/tests/unit/sampletones_application/utils/gui/keyboard/test_router.py @@ -104,20 +104,82 @@ def test_push_marks_modal_open(self) -> None: def test_nested_modals_stay_open_until_last_pop(self) -> None: router = KeyRouter() - router.push_modal(_RecordingModal()) - router.push_modal(_RecordingModal()) + lower = _RecordingModal() + upper = _RecordingModal() + router.push_modal(lower) + router.push_modal(upper) - router.pop_modal() + router.pop_modal(upper) assert router.is_modal_open def test_pop_without_a_modal_stays_closed(self) -> None: router = KeyRouter() - router.pop_modal() + router.pop_modal(_RecordingModal()) + + assert not router.is_modal_open + + def test_a_claim_released_beneath_the_top_leaves_the_top_answering(self) -> None: + """A dialog closing while a later one stands gives up its own claim and none other.""" + router = KeyRouter() + lower = _RecordingModal() + upper = _RecordingModal() + router.push_modal(lower) + router.push_modal(upper) + + router.pop_modal(lower) + router.route(_event()) + + assert upper.keys == [_event().key] + assert lower.keys == [] + + def test_a_claim_released_beneath_the_top_is_gone(self) -> None: + router = KeyRouter() + lower = _RecordingModal() + upper = _RecordingModal() + router.push_modal(lower) + router.push_modal(upper) + + router.pop_modal(lower) + router.pop_modal(upper) assert not router.is_modal_open + def test_releasing_a_handler_holding_no_claim_changes_nothing(self) -> None: + router = KeyRouter() + standing = _RecordingModal() + router.push_modal(standing) + + router.pop_modal(_RecordingModal()) + router.route(_event()) + + assert standing.keys == [_event().key] + + def test_a_handler_claiming_twice_gives_up_one_claim_at_a_time(self) -> None: + router = KeyRouter() + handler = _RecordingModal() + router.push_modal(handler) + router.push_modal(handler) + + router.pop_modal(handler) + + assert router.is_modal_open + + def test_a_handler_claiming_twice_gives_up_its_latest_claim(self) -> None: + """The claim released is the one standing highest, so what it stood above answers again.""" + router = KeyRouter() + handler = _RecordingModal() + other = _RecordingModal() + router.push_modal(handler) + router.push_modal(other) + router.push_modal(handler) + + router.pop_modal(handler) + router.route(_event()) + + assert other.keys == [_event().key] + def test_an_open_modal_claims_the_key_and_suppresses_lower_scopes(self) -> None: router = KeyRouter() log: List[str] = [] @@ -155,8 +217,9 @@ def test_a_closed_modal_returns_the_keyboard_to_lower_scopes(self) -> None: priority=PRIORITY_SHORTCUT, active=lambda: True, ) - router.push_modal(_RecordingModal()) - router.pop_modal() + modal = _RecordingModal() + router.push_modal(modal) + router.pop_modal(modal) claimed = router.route(_event()) diff --git a/tests/unit/sampletones_application/utils/gui/test_modal_queue.py b/tests/unit/sampletones_application/utils/gui/test_modal_queue.py new file mode 100644 index 000000000..62be65bed --- /dev/null +++ b/tests/unit/sampletones_application/utils/gui/test_modal_queue.py @@ -0,0 +1,256 @@ +from typing import Final, List + +import pytest + +from sampletones_application.utils.gui.modal_queue import ModalQueue +from sampletones_shared.types.callback import VoidCallback +from tests.suite.frames import Frames, held_frames + +__all__ = ["held_frames"] + +FIRST: Final[str] = "first" +SECOND: Final[str] = "second" +THIRD: Final[str] = "third" +DIALOG: Final[str] = "dialog" +PROMPT: Final[str] = "prompt" + + +class Screen: + """The windows the line built, in the order it built them, and what each one stands for.""" + + def __init__(self) -> None: + self.built: List[str] = [] + self.revealed: List[str] = [] + + def builder(self, tag: str) -> VoidCallback: + return lambda: self.built.append(tag) + + def open(self, tag: str) -> None: + ModalQueue.open(tag, self.builder(tag)) + + def revealer(self, tag: str) -> VoidCallback: + return lambda: self.revealed.append(tag) + + +@pytest.fixture +def screen() -> Screen: + return Screen() + + +class TestTheFreeScreen: + """A modal asked for while nothing holds the screen opens at once.""" + + def test_the_first_modal_opens_at_once(self, screen: Screen) -> None: + screen.open(FIRST) + + assert screen.built == [FIRST] + + def test_a_modal_left_frees_the_screen_a_frame_later(self, screen: Screen, held_frames: Frames) -> None: + """The frame the modal left in still draws it, so a modal built there would open hidden.""" + screen.open(FIRST) + ModalQueue.leave(FIRST) + + screen.open(SECOND) + assert screen.built == [FIRST] + + held_frames.render() + + assert screen.built == [FIRST, SECOND] + + +class TestTheLine: + """A modal asked for while another conversation holds the screen waits its turn.""" + + def test_a_modal_asked_for_while_another_stands_waits(self, screen: Screen, held_frames: Frames) -> None: + screen.open(FIRST) + + screen.open(SECOND) + held_frames.render() + + assert screen.built == [FIRST] + + def test_the_waiting_modal_opens_a_frame_after_the_standing_one_leaves( + self, + screen: Screen, + held_frames: Frames, + ) -> None: + screen.open(FIRST) + screen.open(SECOND) + + ModalQueue.leave(FIRST) + assert screen.built == [FIRST] + held_frames.render() + + assert screen.built == [FIRST, SECOND] + + def test_modals_waiting_open_in_the_order_they_were_asked_for( + self, + screen: Screen, + held_frames: Frames, + ) -> None: + screen.open(FIRST) + screen.open(SECOND) + screen.open(THIRD) + + ModalQueue.leave(FIRST) + held_frames.render() + ModalQueue.leave(SECOND) + held_frames.render() + + assert screen.built == [FIRST, SECOND, THIRD] + + def test_asking_again_for_a_waiting_modal_keeps_one_place_with_the_newer_request( + self, + screen: Screen, + held_frames: Frames, + ) -> None: + newer: List[str] = [] + screen.open(FIRST) + screen.open(SECOND) + screen.open(THIRD) + + ModalQueue.open(SECOND, lambda: newer.append(SECOND)) + ModalQueue.leave(FIRST) + held_frames.render() + + assert screen.built == [FIRST, THIRD] + assert not newer + + def test_a_modal_taken_away_while_it_waits_never_opens(self, screen: Screen, held_frames: Frames) -> None: + screen.open(FIRST) + screen.open(SECOND) + + ModalQueue.leave(SECOND) + ModalQueue.leave(FIRST) + held_frames.render() + + assert screen.built == [FIRST] + + +class TestAConversation: + """A modal and what it hands the screen to keep the screen until the last of them leaves.""" + + def test_what_an_answer_raises_opens_ahead_of_the_line(self, screen: Screen, held_frames: Frames) -> None: + screen.open(FIRST) + screen.open(SECOND) + + ModalQueue.leave(FIRST) + ModalQueue.hand_off(lambda: screen.open(PROMPT)) + held_frames.render() + + assert screen.built == [FIRST, PROMPT] + + def test_the_line_moves_once_the_answer_raised_nothing(self, screen: Screen, held_frames: Frames) -> None: + screen.open(FIRST) + screen.open(SECOND) + + ModalQueue.leave(FIRST) + ModalQueue.hand_off(lambda: None) + held_frames.render() + + assert screen.built == [FIRST, SECOND] + + def test_a_dialog_standing_aside_keeps_the_screen_for_its_prompt( + self, + screen: Screen, + held_frames: Frames, + ) -> None: + screen.open(DIALOG) + screen.open(SECOND) + + ModalQueue.step_aside(DIALOG) + ModalQueue.hand_off(lambda: screen.open(PROMPT)) + held_frames.render() + + assert screen.built == [DIALOG, PROMPT] + + def test_the_dialog_comes_back_ahead_of_the_line(self, screen: Screen, held_frames: Frames) -> None: + screen.open(DIALOG) + screen.open(SECOND) + ModalQueue.step_aside(DIALOG) + ModalQueue.hand_off(lambda: screen.open(PROMPT)) + held_frames.render() + + ModalQueue.leave(PROMPT) + ModalQueue.hand_off(lambda: ModalQueue.come_back(DIALOG, screen.revealer(DIALOG))) + held_frames.render() + + assert screen.revealed == [DIALOG] + assert screen.built == [DIALOG, PROMPT] + + def test_the_line_moves_once_the_dialog_itself_leaves(self, screen: Screen, held_frames: Frames) -> None: + screen.open(DIALOG) + screen.open(SECOND) + ModalQueue.step_aside(DIALOG) + ModalQueue.hand_off(lambda: screen.open(PROMPT)) + held_frames.render() + + ModalQueue.leave(PROMPT) + ModalQueue.hand_off(lambda: ModalQueue.leave(DIALOG)) + held_frames.render(2) + + assert screen.built == [DIALOG, PROMPT, SECOND] + + def test_a_second_modal_one_answer_raises_waits_for_the_first( + self, + screen: Screen, + held_frames: Frames, + ) -> None: + """DearPyGui shows one of them, so the answer's second modal opens once its first one leaves.""" + screen.open(FIRST) + screen.open(THIRD) + ModalQueue.leave(FIRST) + + def answer() -> None: + screen.open(PROMPT) + screen.open(SECOND) + + ModalQueue.hand_off(answer) + held_frames.render() + ModalQueue.leave(PROMPT) + held_frames.render() + + assert screen.built == [FIRST, PROMPT, SECOND] + + def test_a_modal_raised_where_the_answer_closed_one_opens_a_frame_later( + self, + screen: Screen, + held_frames: Frames, + ) -> None: + """The frame the answer closed a modal in still draws it, and the answer's modal keeps its turn.""" + screen.open(DIALOG) + screen.open(THIRD) + + def answer() -> None: + ModalQueue.leave(DIALOG) + screen.open(SECOND) + + ModalQueue.hand_off(answer) + held_frames.render() + assert screen.built == [DIALOG] + + held_frames.render() + + assert screen.built == [DIALOG, SECOND] + + def test_closing_a_dialog_standing_aside_frees_the_screen_at_once( + self, + screen: Screen, + held_frames: Frames, + ) -> None: + """A dialog standing aside is drawn nowhere, so the screen it leaves is free in the same frame.""" + screen.open(DIALOG) + ModalQueue.step_aside(DIALOG) + ModalQueue.hand_off(lambda: screen.open(PROMPT)) + held_frames.render() + ModalQueue.leave(PROMPT) + held_frames.render() + + def answer() -> None: + ModalQueue.leave(DIALOG) + screen.open(SECOND) + + ModalQueue.hand_off(answer) + held_frames.render() + + assert screen.built == [DIALOG, PROMPT, SECOND] diff --git a/tests/unit/sampletones_core/exporters/rows/__init__.py b/tests/unit/sampletones_core/exporters/rows/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_core/exporters/rows/test_levels.py b/tests/unit/sampletones_core/exporters/rows/test_levels.py new file mode 100644 index 000000000..796d7ef06 --- /dev/null +++ b/tests/unit/sampletones_core/exporters/rows/test_levels.py @@ -0,0 +1,206 @@ +from dataclasses import dataclass +from typing import Dict, Final, List, Optional, Tuple + +import pytest + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.general import MAX_VOLUME, SILENT_VOLUME +from sampletones_core.exporters.rows.levels import RowPlace, cell_volume, full_level_notes +from sampletones_core.performance.modifiers import TRIANGLE_LOUDEST_SILENT_VOLUME +from sampletones_core.project.patterns.channel import Channel +from sampletones_core.project.patterns.pattern import Pattern +from sampletones_core.project.patterns.row import Row +from sampletones_core.project.song import Song +from sampletones_core.project.voices.note_off import NoteOff +from sampletones_core.project.voices.note_on import NoteOn +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + +ROWS_PER_PATTERN: Final[int] = 4 +VOICE: Final[str] = "voice" +CHANNEL: Final[ChannelName] = ChannelName.PULSE1 +QUIET_VOLUME: Final[int] = 5 +LOUD_VOLUME: Final[int] = 12 +TRANSPOSE: Final[int] = 3 + +NOTE: Final[Row] = Row(command=NoteOn(voice_id=VOICE)) +QUIET_ROW: Final[Row] = Row(volume=QUIET_VOLUME) +FULL_ROW: Final[Row] = Row(volume=MAX_VOLUME) + + +def _song( + patterns: Dict[int, List[Row]], + order: List[Optional[int]], +) -> Song: + """A song of one channel's patterns, every other channel left empty.""" + pools = { + channel_name: Channel( + name=channel_name, + patterns=( + {index: Pattern(rows=rows) for index, rows in patterns.items()} if channel_name == CHANNEL else {} + ), + ) + for channel_name in ChannelName.items() + } + return Song( + rows_per_pattern=ROWS_PER_PATTERN, + order=[{CHANNEL: index} for index in order], + channels=pools, + ) + + +def _rows(*cells: Tuple[int, Row]) -> List[Row]: + rows = [Row() for _ in range(ROWS_PER_PATTERN)] + for row_index, row in cells: + rows[row_index] = row + + return rows + + +def _place(order_position: int, pattern_index: int, row_index: int) -> RowPlace: + return RowPlace(order_position=order_position, pattern_index=pattern_index, row_index=row_index) + + +class TestTheNotesWritingTheFullLevel: + """The song starts a note at the full level wherever its row states none, while a tracker carries + the last level its column wrote into the note, so such a note is written at the full level + wherever the tracker would carry another into it. + """ + + def test_a_note_after_a_quieter_row_writes_the_full_level(self) -> None: + song = _song({0: _rows((0, QUIET_ROW), (2, NOTE))}, [0]) + + assert full_level_notes(song, CHANNEL) == {_place(0, 0, 2)} + + def test_a_note_the_channel_reaches_at_the_full_level_writes_nothing(self) -> None: + song = _song({0: _rows((0, FULL_ROW), (2, NOTE))}, [0]) + + assert full_level_notes(song, CHANNEL) == frozenset() + + def test_a_note_stating_its_own_level_writes_nothing_more(self) -> None: + song = _song({0: _rows((0, QUIET_ROW), (2, Row(command=NoteOn(voice_id=VOICE), volume=LOUD_VOLUME)))}, [0]) + + assert full_level_notes(song, CHANNEL) == frozenset() + + def test_a_note_after_one_the_song_starts_at_full_writes_nothing(self) -> None: + """A note whose row states no level leaves the tracker at the full level too.""" + song = _song({0: _rows((0, QUIET_ROW), (1, NOTE), (3, NOTE))}, [0]) + + assert full_level_notes(song, CHANNEL) == {_place(0, 0, 1)} + + def test_a_note_off_stating_a_level_is_carried_into_the_next_note(self) -> None: + """The song leaves the level alone on a note-off, while the tracker's column takes the level + its cell writes, so the next note is reached at that level. + """ + song = _song({0: _rows((0, NOTE), (1, Row(command=NoteOff(), volume=QUIET_VOLUME)), (3, NOTE))}, [0]) + + assert full_level_notes(song, CHANNEL) == {_place(0, 0, 3)} + + def test_a_transpose_alone_leaves_the_level_alone(self) -> None: + song = _song({0: _rows((0, QUIET_ROW), (1, Row(transpose=TRANSPOSE)), (2, NOTE))}, [0]) + + assert full_level_notes(song, CHANNEL) == {_place(0, 0, 2)} + + def test_the_level_crosses_into_the_next_frame(self) -> None: + song = _song({0: _rows((3, QUIET_ROW)), 1: _rows((0, NOTE))}, [0, 1]) + + assert full_level_notes(song, CHANNEL) == {_place(1, 1, 0)} + + def test_the_level_crosses_a_frame_the_channel_rests_in(self) -> None: + song = _song({0: _rows((3, QUIET_ROW)), 1: _rows((0, NOTE))}, [0, None, 1]) + + assert full_level_notes(song, CHANNEL) == {_place(2, 1, 0)} + + def test_the_level_the_song_ends_on_reaches_its_first_note(self) -> None: + """A tracker returns to the first frame at the end of the order, keeping the level the + channel was left at, so the song's first note is reached at the level it ends on. + """ + song = _song({0: _rows((0, NOTE), (3, QUIET_ROW))}, [0]) + + assert full_level_notes(song, CHANNEL) == {_place(0, 0, 0)} + + def test_a_pattern_is_reached_in_every_frame_that_plays_it(self) -> None: + song = _song({0: _rows((0, NOTE)), 1: _rows((0, QUIET_ROW))}, [0, 1, 0]) + + assert full_level_notes(song, CHANNEL) == {_place(2, 0, 0)} + + def test_a_song_stating_no_level_writes_none(self) -> None: + song = _song({0: _rows((0, NOTE), (2, NOTE))}, [0, 0]) + + assert full_level_notes(song, CHANNEL) == frozenset() + + +class TestTheLevelACellStates(BaseTestSuite): + """A cell states the row's own level, the full level on a note that writes it, and nothing + otherwise; the triangle states silence at a level the song rests it at. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + row: Row + channel_name: ChannelName + full_level: bool + expected: Optional[int] + + test_cases: Tuple["TestTheLevelACellStates.TestCase", ...] = ( + TestCase( + label="a stated level", + row=QUIET_ROW, + channel_name=CHANNEL, + full_level=False, + expected=QUIET_VOLUME, + ), + TestCase( + label="a note writing the full level", + row=NOTE, + channel_name=CHANNEL, + full_level=True, + expected=MAX_VOLUME, + ), + TestCase( + label="a note carrying the level", + row=NOTE, + channel_name=CHANNEL, + full_level=False, + expected=None, + ), + TestCase( + label="silence", + row=Row(volume=SILENT_VOLUME), + channel_name=CHANNEL, + full_level=False, + expected=SILENT_VOLUME, + ), + TestCase( + label="a quiet level on the pulse", + row=Row(volume=TRIANGLE_LOUDEST_SILENT_VOLUME), + channel_name=CHANNEL, + full_level=False, + expected=TRIANGLE_LOUDEST_SILENT_VOLUME, + ), + TestCase( + label="the loudest level the triangle rests at", + row=Row(volume=TRIANGLE_LOUDEST_SILENT_VOLUME), + channel_name=ChannelName.TRIANGLE, + full_level=False, + expected=SILENT_VOLUME, + ), + TestCase( + label="the quietest level the triangle sounds at", + row=Row(volume=TRIANGLE_LOUDEST_SILENT_VOLUME + 1), + channel_name=ChannelName.TRIANGLE, + full_level=False, + expected=TRIANGLE_LOUDEST_SILENT_VOLUME + 1, + ), + TestCase( + label="a triangle note writing the full level", + row=NOTE, + channel_name=ChannelName.TRIANGLE, + full_level=True, + expected=MAX_VOLUME, + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_cell_states_the_level(self, test_case: "TestTheLevelACellStates.TestCase") -> None: + assert cell_volume(test_case.row, test_case.channel_name, full_level=test_case.full_level) == test_case.expected diff --git a/tests/unit/sampletones_core/exporters/rows/test_pitch.py b/tests/unit/sampletones_core/exporters/rows/test_pitch.py new file mode 100644 index 000000000..82a2e8e5f --- /dev/null +++ b/tests/unit/sampletones_core/exporters/rows/test_pitch.py @@ -0,0 +1,92 @@ +from dataclasses import dataclass +from typing import Final, Tuple + +import pytest + +from sampletones_core.constants.general import MAX_PITCH, MIN_PITCH +from sampletones_core.exporters.rows.pitch import FLAT_CONTOUR_STEP, highest_step, written_pitch +from sampletones_core.utils.frequencies import transpose_pitch +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + +CONTOUR: Final[Tuple[int, ...]] = (-5, 0, 5) +CONTOUR_TOP: Final[int] = max(CONTOUR) +FLAT_CONTOUR: Final[Tuple[int, ...]] = (FLAT_CONTOUR_STEP,) +MIDDLE_PITCH: Final[int] = 60 + + +class TestTheHighestStep: + def test_the_highest_step_of_a_contour(self) -> None: + assert highest_step(CONTOUR) == CONTOUR_TOP + + def test_a_contour_of_no_steps_moves_nothing(self) -> None: + assert highest_step(()) == FLAT_CONTOUR_STEP + + +class TestTheWrittenPitch(BaseTestSuite): + """The song holds each tick's transposed pitch within the range the channels play, and a tracker + moves the written note by the contour's step each tick. The written pitch is the one that keeps + each tick the song plays within that range on its own note. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + pitch: int + contour: Tuple[int, ...] + expected: int + + test_cases: Tuple["TestTheWrittenPitch.TestCase", ...] = ( + TestCase( + label="within the range", + pitch=MIDDLE_PITCH, + contour=CONTOUR, + expected=MIDDLE_PITCH, + ), + TestCase( + label="a flat contour below the range", + pitch=MIN_PITCH - 3, + contour=FLAT_CONTOUR, + expected=MIN_PITCH, + ), + TestCase( + label="a contour reaching into the range", + pitch=MIN_PITCH - 2, + contour=CONTOUR, + expected=MIN_PITCH - 2, + ), + TestCase( + label="a contour lying below the range", + pitch=MIN_PITCH - 10, + contour=CONTOUR, + expected=MIN_PITCH - CONTOUR_TOP, + ), + TestCase( + label="a flat contour above the range", + pitch=MAX_PITCH + 4, + contour=FLAT_CONTOUR, + expected=MAX_PITCH, + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_pitch_written(self, test_case: "TestTheWrittenPitch.TestCase") -> None: + assert written_pitch(test_case.pitch, highest_step(test_case.contour)) == test_case.expected + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_every_tick_the_song_plays_within_the_range_keeps_its_note( + self, + test_case: "TestTheWrittenPitch.TestCase", + ) -> None: + """A tick the song holds at the lowest pitch sounds at the tracker's lowest period instead, + and the highest tick of a contour lying below the range lands on the lowest pitch itself. + """ + contour_top = highest_step(test_case.contour) + written = written_pitch(test_case.pitch, contour_top) + + for step in test_case.contour: + song_pitch = transpose_pitch(test_case.pitch, step) + tracker_pitch = min(MAX_PITCH, written + step) + if song_pitch > MIN_PITCH or step == contour_top: + assert tracker_pitch == song_pitch + else: + assert tracker_pitch <= MIN_PITCH diff --git a/tests/unit/sampletones_core/exporters/rows/test_transpose.py b/tests/unit/sampletones_core/exporters/rows/test_transpose.py new file mode 100644 index 000000000..6c48741df --- /dev/null +++ b/tests/unit/sampletones_core/exporters/rows/test_transpose.py @@ -0,0 +1,143 @@ +from typing import Dict, Final, FrozenSet, List, Optional, Tuple + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.exporters.rows.levels import RowPlace +from sampletones_core.exporters.rows.transpose import ( + NOTE_TRANSPOSE, + Repitch, + SoundingNote, + sounding_notes, +) +from sampletones_core.project.patterns.channel import Channel +from sampletones_core.project.patterns.pattern import Pattern +from sampletones_core.project.patterns.row import Row +from sampletones_core.project.song import Song +from sampletones_core.project.voices.note_off import NoteOff +from sampletones_core.project.voices.note_on import NoteOn + +ROWS_PER_PATTERN: Final[int] = 4 +VOICE: Final[str] = "voice" +SILENT_VOICE: Final[str] = "silent" +CHANNEL: Final[ChannelName] = ChannelName.PULSE1 +NOTE_ON_TRANSPOSE: Final[int] = 5 +RAISED: Final[int] = 2 +LOWERED: Final[int] = -3 +INSTRUMENTS: Final[FrozenSet[Tuple[str, ChannelName]]] = frozenset({(VOICE, CHANNEL)}) + + +def _song( + patterns: Dict[int, List[Row]], + order: List[Optional[int]], +) -> Song: + """A song of one channel's patterns, every other channel left empty.""" + pools = { + channel_name: Channel( + name=channel_name, + patterns=( + {index: Pattern(rows=rows) for index, rows in patterns.items()} if channel_name == CHANNEL else {} + ), + ) + for channel_name in ChannelName.items() + } + return Song( + rows_per_pattern=ROWS_PER_PATTERN, + order=[{CHANNEL: index} for index in order], + channels=pools, + ) + + +def _rows(*cells: Tuple[int, Row]) -> List[Row]: + rows = [Row() for _ in range(ROWS_PER_PATTERN)] + for row_index, row in cells: + rows[row_index] = row + + return rows + + +def _place(order_position: int, pattern_index: int, row_index: int) -> RowPlace: + return RowPlace(order_position=order_position, pattern_index=pattern_index, row_index=row_index) + + +def _note(transpose: Optional[int] = None) -> Row: + return Row(command=NoteOn(voice_id=VOICE), transpose=transpose) + + +class TestTheNotesATransposeRowMoves: + """A row stating a transpose and no note moves the note already sounding, which keeps playing + from the tick it reached, so the walk names each such row beside the note it moves and how long + that note has sounded. + """ + + def test_a_transpose_row_reaches_the_note_before_it(self) -> None: + song = _song({0: _rows((0, _note()), (2, Row(transpose=RAISED)))}, [0]) + + assert sounding_notes(song, CHANNEL, INSTRUMENTS) == ( + SoundingNote( + place=_place(0, 0, 0), + voice_id=VOICE, + transpose=NOTE_TRANSPOSE, + repitches=(Repitch(place=_place(0, 0, 2), transpose=RAISED, rows=2),), + ), + ) + + def test_the_note_keeps_the_transpose_its_own_row_states(self) -> None: + song = _song({0: _rows((0, _note(NOTE_ON_TRANSPOSE)), (1, Row(transpose=RAISED)))}, [0]) + + (note,) = sounding_notes(song, CHANNEL, INSTRUMENTS) + + assert note.transpose == NOTE_ON_TRANSPOSE + + def test_every_transpose_row_of_a_note_is_named_in_the_order_the_song_plays_them(self) -> None: + song = _song({0: _rows((0, _note()), (1, Row(transpose=RAISED)), (3, Row(transpose=LOWERED)))}, [0]) + + (note,) = sounding_notes(song, CHANNEL, INSTRUMENTS) + + assert [(repitch.transpose, repitch.rows) for repitch in note.repitches] == [(RAISED, 1), (LOWERED, 3)] + + def test_a_note_is_counted_across_frames(self) -> None: + """A frame plays a whole pattern, so a transpose row in a later frame counts every row between.""" + song = _song({0: _rows((1, _note())), 1: _rows((2, Row(transpose=RAISED)))}, [0, 1]) + + (note,) = sounding_notes(song, CHANNEL, INSTRUMENTS) + + assert note.repitches == (Repitch(place=_place(1, 1, 2), transpose=RAISED, rows=5),) + + def test_a_frame_leaving_the_channel_empty_keeps_the_note_sounding(self) -> None: + song = _song({0: _rows((0, _note())), 1: _rows((1, Row(transpose=RAISED)))}, [0, None, 1]) + + (note,) = sounding_notes(song, CHANNEL, INSTRUMENTS) + + assert note.repitches[0].rows == 2 * ROWS_PER_PATTERN + 1 + + def test_a_pattern_the_order_plays_twice_is_reached_in_each_frame(self) -> None: + song = _song({0: _rows((0, _note()), (2, Row(transpose=RAISED)))}, [0, 0]) + + notes = sounding_notes(song, CHANNEL, INSTRUMENTS) + + assert [note.place.order_position for note in notes] == [0, 1] + + def test_a_note_off_ends_the_note(self) -> None: + song = _song({0: _rows((0, _note()), (1, Row(command=NoteOff())), (2, Row(transpose=RAISED)))}, [0]) + + assert sounding_notes(song, CHANNEL, INSTRUMENTS) == () + + def test_a_note_the_channel_has_no_instrument_for_moves_nothing(self) -> None: + """Such a note-on is written as a note cut, so the channel is silent when the row comes.""" + song = _song( + {0: _rows((0, Row(command=NoteOn(voice_id=SILENT_VOICE))), (2, Row(transpose=RAISED)))}, + [0], + ) + + assert sounding_notes(song, CHANNEL, INSTRUMENTS) == () + + def test_a_transpose_row_before_any_note_moves_nothing(self) -> None: + song = _song({0: _rows((0, Row(transpose=RAISED)), (2, _note()))}, [0]) + + assert sounding_notes(song, CHANNEL, INSTRUMENTS) == () + + def test_a_note_no_transpose_row_reaches_is_left_out(self) -> None: + song = _song({0: _rows((0, _note()), (1, Row(volume=RAISED)), (2, _note()), (3, Row(transpose=RAISED)))}, [0]) + + (note,) = sounding_notes(song, CHANNEL, INSTRUMENTS) + + assert note.place == _place(0, 0, 2) diff --git a/tests/unit/sampletones_core/exporters/test_skipped.py b/tests/unit/sampletones_core/exporters/test_skipped.py index 761485ee3..873184bdd 100644 --- a/tests/unit/sampletones_core/exporters/test_skipped.py +++ b/tests/unit/sampletones_core/exporters/test_skipped.py @@ -1,7 +1,7 @@ from typing import Dict, List, Optional, Set, Tuple from sampletones_core.constants.enums import ChannelName -from sampletones_core.exporters.skipped import SkippedRow, find_skipped_rows +from sampletones_core.exporters.skipped import SkippedRow, SkipReason, find_skipped_rows, in_song_order from sampletones_core.project.patterns.channel import Channel from sampletones_core.project.patterns.pattern import Pattern from sampletones_core.project.patterns.row import Row @@ -47,7 +47,13 @@ def test_a_note_on_naming_a_voice_without_an_instrument_on_its_channel_is_found( ) assert find_skipped_rows(song, INSTRUMENTS) == ( - SkippedRow(voice_id=KNOWN, channel=ChannelName.PULSE2, order_position=0, row_index=1), + SkippedRow( + voice_id=KNOWN, + channel=ChannelName.PULSE2, + order_position=0, + row_index=1, + reason=SkipReason.NO_INSTRUMENT, + ), ) def test_a_note_on_with_an_instrument_on_its_channel_is_left_alone(self) -> None: @@ -97,3 +103,37 @@ def test_the_rows_come_in_the_order_the_song_plays_them(self) -> None: (0, ChannelName.TRIANGLE, 0), (1, ChannelName.PULSE2, 3), ] + + +def _reported( + order_position: int, + channel: ChannelName, + row_index: int, + reason: SkipReason, +) -> SkippedRow: + return SkippedRow( + voice_id=KNOWN, + channel=channel, + order_position=order_position, + row_index=row_index, + reason=reason, + ) + + +class TestTheRowsInSongOrder: + def test_rows_gathered_apart_come_frame_by_frame_then_channel_by_channel_then_row_by_row(self) -> None: + rows = ( + _reported(2, ChannelName.PULSE1, 0, SkipReason.UNREACHED_TRANSPOSE), + _reported(0, ChannelName.TRIANGLE, 1, SkipReason.NO_INSTRUMENT), + _reported(0, ChannelName.PULSE2, 3, SkipReason.UNREACHED_TRANSPOSE), + _reported(0, ChannelName.PULSE2, 1, SkipReason.NO_INSTRUMENT), + ) + + ordered = [(row.order_position, row.channel, row.row_index) for row in in_song_order(rows)] + + assert ordered == [ + (0, ChannelName.PULSE2, 1), + (0, ChannelName.PULSE2, 3), + (0, ChannelName.TRIANGLE, 1), + (2, ChannelName.PULSE1, 0), + ] diff --git a/tests/unit/sampletones_core/formats/bitphase/model/__init__.py b/tests/unit/sampletones_core/formats/bitphase/model/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_core/formats/bitphase/model/test_table.py b/tests/unit/sampletones_core/formats/bitphase/model/test_table.py new file mode 100644 index 000000000..df860d7a7 --- /dev/null +++ b/tests/unit/sampletones_core/formats/bitphase/model/test_table.py @@ -0,0 +1,86 @@ +from dataclasses import dataclass +from typing import Final, Tuple + +import pytest + +from sampletones_core.formats.bitphase.model.table import BitphaseTable +from sampletones_core.formats.bitphase.specification.instruments import LOOP_FROM_START +from tests.suite.base import BaseTestSuite +from tests.suite.bitphase import LoadedTable +from tests.suite.case import BaseRegularTestCase + +STEPS: Final[Tuple[int, ...]] = (0, 4, 7, 12, 7, 4) +MIDDLE_LOOP: Final[int] = 3 +HELD_LOOP: Final[int] = len(STEPS) - 1 +TICKS_PLAYED: Final[int] = 40 +SHIFT: Final[int] = -5 +TABLE_ID: Final[int] = 7 +NAME: Final[str] = "Moved" + + +def table(loop: int) -> BitphaseTable: + return BitphaseTable(id=0, rows=STEPS, loop=loop, name="Contour") + + +def engine_step(source: BitphaseTable, tick: int) -> int: + """The step Bitphase's own reading of a table plays on a tick, from the suite's copy of the engine.""" + loaded = LoadedTable(id=source.id, loop=source.loop, name=source.name, rows=list(source.rows), additive=False) + return loaded.step(tick) + + +class TestTheStepATableStandsAt(BaseTestSuite): + """Playback advances a table a step per tick and circles from its loop, from the first step where + the loop stands at the start, so the step a transpose row places its table at is the one the + engine would have reached. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + loop: int + + test_cases: Tuple["TestTheStepATableStandsAt.TestCase", ...] = ( + TestCase(label="circling whole", loop=LOOP_FROM_START), + TestCase(label="circling from the middle", loop=MIDDLE_LOOP), + TestCase(label="holding the last step", loop=HELD_LOOP), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_step_is_the_one_the_engine_plays(self, test_case: "TestTheStepATableStandsAt.TestCase") -> None: + source = table(test_case.loop) + + assert [source.rows[source.position_at(tick)] for tick in range(TICKS_PLAYED)] == [ + engine_step(source, tick) for tick in range(TICKS_PLAYED) + ] + + +class TestAMovedTable(BaseTestSuite): + """A moved table plays this table's steps moved by the shift from the step it opens on, so a + transpose row reaching a step no ornament position names still sounds where the note stood. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + loop: int + start: int + + test_cases: Tuple["TestAMovedTable.TestCase", ...] = ( + TestCase(label="opening on the first step", loop=MIDDLE_LOOP, start=0), + TestCase(label="opening before the loop", loop=MIDDLE_LOOP, start=MIDDLE_LOOP - 1), + TestCase(label="opening past the loop", loop=MIDDLE_LOOP, start=MIDDLE_LOOP + 1), + TestCase(label="opening within a whole circle", loop=LOOP_FROM_START, start=MIDDLE_LOOP), + TestCase(label="opening on a held step", loop=HELD_LOOP, start=HELD_LOOP), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_it_plays_what_the_table_plays_from_its_start(self, test_case: "TestAMovedTable.TestCase") -> None: + source = table(test_case.loop) + moved = source.moved(table_id=TABLE_ID, shift=SHIFT, start=test_case.start, name=NAME) + + assert [engine_step(moved, tick) for tick in range(TICKS_PLAYED)] == [ + engine_step(source, test_case.start + tick) + SHIFT for tick in range(TICKS_PLAYED) + ] + + def test_it_takes_the_id_and_the_name_it_is_given(self) -> None: + moved = table(MIDDLE_LOOP).moved(table_id=TABLE_ID, shift=SHIFT, start=0, name=NAME) + + assert (moved.id, moved.name) == (TABLE_ID, NAME) diff --git a/tests/unit/sampletones_core/formats/bitphase/test_envelopes.py b/tests/unit/sampletones_core/formats/bitphase/test_envelopes.py index 2166c08db..f7eec5d24 100644 --- a/tests/unit/sampletones_core/formats/bitphase/test_envelopes.py +++ b/tests/unit/sampletones_core/formats/bitphase/test_envelopes.py @@ -97,14 +97,26 @@ def test_a_channel_without_a_duty_envelope_holds_one_waveform(self) -> None: ) assert waveform_values(envelopes) == [FLAT_PULSE_WIDTH] + def test_a_noise_channel_without_a_duty_envelope_holds_the_long_mode(self) -> None: + """Every note starts on the long mode where the slice leaves its mode, as a note does in the app.""" + envelopes = features_to_envelopes( + build_features(VOLUME_ENVELOPE), + ChannelName.NOISE, + tuning_table=DEFAULT_TUNING_TABLE, + ) + assert waveform_values(envelopes) == [NOISE_MODE_LONG] + def test_a_noise_contour_takes_the_offsets_that_move_its_period(self) -> None: + """The note index carries the period itself, so a step moves the index by as much, wrapped + into the sixteen periods the channel has. + """ steps = [0, 1, -1, 5] envelopes = features_to_envelopes( build_features([15] * len(steps), arpeggio=steps), ChannelName.NOISE, tuning_table=DEFAULT_TUNING_TABLE, ) - assert list(envelopes.table_rows) == [(-step) % NUM_PERIODS for step in steps] + assert list(envelopes.table_rows) == [step % NUM_PERIODS for step in steps] class TestEachDimensionKeepsItsOwnLength: diff --git a/tests/unit/sampletones_core/formats/bitphase/test_notes.py b/tests/unit/sampletones_core/formats/bitphase/test_notes.py index 0f69224fd..6d620f4a9 100644 --- a/tests/unit/sampletones_core/formats/bitphase/test_notes.py +++ b/tests/unit/sampletones_core/formats/bitphase/test_notes.py @@ -3,7 +3,7 @@ import pytest -from sampletones_core.constants.general import NUM_PERIODS +from sampletones_core.constants.general import NOISE_PERIODS, NUM_PERIODS from sampletones_core.formats.bitphase.notes import ( noise_arpeggio_to_table_offset, noise_period_to_note_index, @@ -19,6 +19,7 @@ NOTE_RANGE, NoteName, ) +from tests.suite.bitphase import BITPHASE_NOISE_TIMERS, noise_register from tests.suite.case import BaseRegularTestCase LOWEST_STEP: Final[int] = -NUM_PERIODS @@ -30,9 +31,9 @@ def bitphase_note_value(name: int, octave: int) -> int: return name - int(NoteName.C) + (octave - FIRST_OCTAVE) * NOTE_RANGE -def bitphase_noise_period(index: int) -> int: - """The noise period Bitphase's playback selects for a note index.""" - return NUM_PERIODS - 1 - index % NUM_PERIODS +def sounded_noise_timer(index: int) -> int: + """The noise timer Bitphase's playback sounds for a note index.""" + return BITPHASE_NOISE_TIMERS[noise_register(index)] class TestPitchToNoteIndex: @@ -95,9 +96,18 @@ def test_every_cell_names_a_pitched_semitone(self) -> None: class TestNoisePeriods: + """The project counts noise periods from the slowest, while Bitphase's register counts them + from the fastest, so the note a period is written at sounds the timer the project gives it. + """ + + @pytest.mark.parametrize("period", range(NUM_PERIODS)) + def test_a_period_reaches_the_note_index_that_sounds_it(self, period: int) -> None: + assert sounded_noise_timer(noise_period_to_note_index(period)) == NOISE_PERIODS[period] + @pytest.mark.parametrize("period", range(NUM_PERIODS)) - def test_a_period_reaches_the_note_index_that_selects_it(self, period: int) -> None: - assert bitphase_noise_period(noise_period_to_note_index(period)) == period + def test_a_period_past_the_cycle_wraps_into_the_sixteen_the_channel_has(self, period: int) -> None: + """A transpose walks the period around the sixteen, so a period a cycle up sounds as the one it wraps to.""" + assert noise_period_to_note_index(period + NUM_PERIODS) == noise_period_to_note_index(period) @pytest.mark.parametrize("period", range(NUM_PERIODS)) def test_a_base_note_leaves_a_whole_cycle_of_offsets_playable(self, period: int) -> None: @@ -111,7 +121,7 @@ def test_an_arpeggio_step_moves_the_period_by_that_much(self, step: int) -> None """ for period in range(NUM_PERIODS): index = noise_period_to_note_index(period) + noise_arpeggio_to_table_offset(step) - assert bitphase_noise_period(index) == (period + step) % NUM_PERIODS + assert sounded_noise_timer(index) == NOISE_PERIODS[(period + step) % NUM_PERIODS] @pytest.mark.parametrize("step", range(LOWEST_STEP, HIGHEST_STEP + 1)) def test_every_reached_note_stays_inside_the_tuning_table(self, step: int) -> None: 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 262cfe92a..5ba137185 100644 --- a/tests/unit/sampletones_core/formats/bitphase/test_project_builder.py +++ b/tests/unit/sampletones_core/formats/bitphase/test_project_builder.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass from pathlib import Path from typing import Dict, Final, List, Mapping, Optional, Sequence, Tuple @@ -10,11 +11,11 @@ DEFAULT_CHANNELS, ChannelName, ) -from sampletones_core.constants.general import SILENT_VOLUME -from sampletones_core.exporters.skipped import SkippedRow +from sampletones_core.constants.general import MIN_PITCH, SILENT_VOLUME +from sampletones_core.exporters.skipped import SkippedRow, SkipReason from sampletones_core.features.envelope import Envelope from sampletones_core.formats.bitphase.builder import build_bitphase, project_to_bitphase -from sampletones_core.formats.bitphase.model.pattern import BitphaseRow, EffectCell +from sampletones_core.formats.bitphase.model.pattern import BitphaseRow, EffectCell, NoteCell from sampletones_core.formats.bitphase.model.project import BitphaseProject from sampletones_core.formats.bitphase.notes import ( note_index_to_note_cell, @@ -29,9 +30,15 @@ ) from sampletones_core.formats.bitphase.specification.instruments import LOOP_FROM_START from sampletones_core.formats.bitphase.specification.patterns import ( + FIRST_OCTAVE, + FULL_VOLUME, + MAX_NOTE_INDEX, + MIN_NOTE_INDEX, NO_INSTRUMENT_CHANGE, NO_TABLE_CHANGE, NO_VOLUME_CHANGE, + NOTE_INDEX_PITCH_OFFSET, + NOTE_RANGE, SYMBOL_BASE, TABLE_COLUMN_OFFSET, VOLUME_OFF, @@ -41,6 +48,7 @@ from sampletones_core.instructions.implementation.pulse import PulseInstruction from sampletones_core.instructions.implementation.triangle import TriangleInstruction from sampletones_core.instructions.instruction import Instruction +from sampletones_core.performance.modifiers import triangle_sounds_at from sampletones_core.project.patterns.channel import Channel from sampletones_core.project.patterns.pattern import Pattern from sampletones_core.project.patterns.row import Row @@ -52,9 +60,14 @@ from sampletones_core.project.voices.note_off import NoteOff from sampletones_core.project.voices.note_on import NoteOn from sampletones_core.project.voices.sample import Sample -from sampletones_core.project.voices.voice import VoiceUnion +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.utils.frequencies import transpose_pitch +from tests.suite.base import BaseTestSuite +from tests.suite.bitphase import BITPHASE_OPENING_PATTERN_VOLUME, pattern_volume +from tests.suite.case import BaseRegularTestCase +from tests.suite.performance import song_row_volumes from tests.suite.stems import single_entry_stems_data RECONSTRUCTION_LENGTH: Final[int] = 4 @@ -71,6 +84,20 @@ GROOVE_TEMPO: Final[int] = 210 GROOVE_TICKS: Final[Tuple[int, ...]] = (5, 4, 4, 4, 5, 4, 4, 4) RETUNED_A4_FREQUENCY: Final[float] = 432.0 +QUIET_VOLUME: Final[int] = 4 +NOTE_OFF_VOLUME: Final[int] = 6 +CLOSING_VOLUME: Final[int] = 9 +FRAME_CLOSING_VOLUME: Final[int] = 2 +QUIET_TRIANGLE_VOLUME: Final[int] = 5 +LOUD_TRIANGLE_VOLUME: Final[int] = 12 +RESTING_TRIANGLE_VOLUME: Final[int] = 7 +NOTE_TRIANGLE_VOLUME: Final[int] = 3 +PLAYED_PASSES: Final[int] = 2 +LOW_PITCH: Final[int] = 36 +LOW_TRANSPOSE: Final[int] = -10 +CONTOUR_PITCHES: Final[Tuple[int, ...]] = (40, 50) +STRADDLING_TRANSPOSE: Final[int] = -14 +SUBMERGED_TRANSPOSE: Final[int] = -20 def build_reconstruction( @@ -104,6 +131,23 @@ def pulse_sample( ) +def contour_sample( + name: str, + pitches: Sequence[int], + *, + config: Config, +) -> Sample: + """A pulse sample sounding each pitch for one frame, so its table moves the note between them.""" + instructions = [PulseInstruction(on=True, pitch=pitch, volume=15, duty_cycle=0) for pitch in pitches] + return Sample( + name=name, + reconstruction=build_reconstruction( + {ChannelName.PULSE1: instructions}, + config=config, + ), + ) + + def triangle_sample( name: str, pitch: int, @@ -260,8 +304,18 @@ def test_a_row_volume_reaches_the_volume_column(self, document: BitphaseProject) row = document.songs[0].patterns[0].channels[int(ChannelIndex.SQUARE1)].rows[TRIGGER_ROW] assert row.volume == ROW_VOLUME - def test_a_row_that_sets_no_volume_leaves_the_column_alone(self, document: BitphaseProject) -> None: + def test_a_note_after_a_quieter_row_starts_at_the_full_level(self, document: BitphaseProject) -> None: + """The song starts a note stating no level at the full level, while Bitphase carries the + level the trigger row set into it, so the note writes the full level. + """ row = document.songs[0].patterns[0].channels[int(ChannelIndex.SQUARE1)].rows[TRANSPOSED_ROW] + assert row.volume == FULL_VOLUME + + def test_a_note_the_channel_reaches_at_the_full_level_leaves_the_column_alone( + self, + document: BitphaseProject, + ) -> None: + row = document.songs[0].patterns[0].channels[int(ChannelIndex.TRIANGLE)].rows[TRIGGER_ROW] assert row.volume == NO_VOLUME_CHANGE def test_a_row_asking_for_silence_silences_the_channel(self, document: BitphaseProject) -> None: @@ -380,6 +434,7 @@ def test_the_row_is_reported_where_the_tracker_shows_it(self, source: Project, l channel=ChannelName.PULSE2, order_position=0, row_index=TRIGGER_ROW, + reason=SkipReason.NO_INSTRUMENT, ), ) @@ -433,3 +488,210 @@ def test_a_project_of_instruments_alone_plays_at_concert_pitch(self) -> None: assert song.a4_tuning_hz == DEFAULT_A4_TUNING assert song.tuning_table == DEFAULT_TUNING_TABLE + + +def rows_with(*cells: Tuple[int, Row]) -> List[Row]: + """A pattern's rows, blank apart from the ones given by their index.""" + rows = [Row() for _ in range(ROWS_PER_PATTERN)] + for row_index, row in cells: + rows[row_index] = row + + return rows + + +def arranged_project( + voices: Sequence[Sample], + patterns: Mapping[ChannelName, Mapping[int, List[Row]]], + order: List[Dict[ChannelName, Optional[int]]], +) -> Project: + """A project playing ``voices`` through the channel patterns ``order`` names.""" + pool: IdentifiedCollection[Sample] = IdentifiedCollection() + for voice in voices: + pool.append(voice) + + channels = { + channel_name: Channel( + name=channel_name, + patterns={index: Pattern(rows=rows) for index, rows in patterns.get(channel_name, {}).items()}, + ) + for channel_name in ChannelName.items() + } + project = Project.create(title="Rows", author="Tester", settings=ProjectSettings()) + project.voices = pool + project.song = Song(rows_per_pattern=ROWS_PER_PATTERN, order=order, channels=channels) + return project + + +def played_volumes(document: BitphaseProject, channel: ChannelIndex) -> List[int]: + """The level Bitphase plays one channel at on each row, the order played through and round again. + + The document returns to its loop point, the first order position, keeping every channel's level, + so a second pass shows what the level the song ends on does to the notes it opens with. + """ + patterns = {pattern.id: pattern for pattern in document.songs[0].patterns} + level = BITPHASE_OPENING_PATTERN_VOLUME + levels: List[int] = [] + for _ in range(PLAYED_PASSES): + for pattern_id in document.pattern_order: + for row in patterns[pattern_id].channels[int(channel)].rows: + level = pattern_volume(level, row.volume) + levels.append(level) + + return levels + + +def cell_pitch(note: NoteCell) -> int: + """The pitch a pattern cell's note column names.""" + return (note.name - int(NoteName.C)) + (note.octave - FIRST_OCTAVE) * NOTE_RANGE + NOTE_INDEX_PITCH_OFFSET + + +class TestTheLevelsAPlayedSongCarries: + """The song starts a note stating no level at the full level and sounds the triangle only above + half volume, while Bitphase carries the last level a cell wrote into every note and sounds the + triangle at any level above silence. The document therefore writes its volume column so that + Bitphase, playing the order through and round again, plays each row the song sounds at the song's + own level. + """ + + @pytest.fixture(name="arranged") + def arranged_fixture(self, lead: Sample, bass: Sample) -> Project: + lead_note = Row(command=NoteOn(voice_id=lead.id)) + bass_note = Row(command=NoteOn(voice_id=bass.id)) + pulse = { + 0: rows_with( + (0, lead_note), + (1, Row(volume=QUIET_VOLUME)), + (2, Row(transpose=TRANSPOSE)), + (3, lead_note), + (4, Row(command=NoteOff(), volume=NOTE_OFF_VOLUME)), + (5, lead_note), + (6, Row(volume=CLOSING_VOLUME)), + ), + 1: rows_with( + (0, lead_note), + (3, Row(volume=FRAME_CLOSING_VOLUME)), + ), + } + triangle = { + 0: rows_with( + (0, bass_note), + (2, Row(volume=QUIET_TRIANGLE_VOLUME)), + (4, Row(volume=LOUD_TRIANGLE_VOLUME)), + (6, bass_note), + ), + 1: rows_with( + (0, Row(volume=RESTING_TRIANGLE_VOLUME)), + (2, bass_note), + (5, Row(command=NoteOn(voice_id=bass.id), volume=NOTE_TRIANGLE_VOLUME)), + ), + } + order: List[Dict[ChannelName, Optional[int]]] = [ + {ChannelName.PULSE1: 0, ChannelName.TRIANGLE: 0}, + {ChannelName.PULSE1: 1, ChannelName.TRIANGLE: 1}, + {ChannelName.PULSE1: None, ChannelName.TRIANGLE: 0}, + {ChannelName.PULSE1: 0, ChannelName.TRIANGLE: None}, + ] + return arranged_project( + (lead, bass), + {ChannelName.PULSE1: pulse, ChannelName.TRIANGLE: triangle}, + order, + ) + + def test_the_pulse_plays_every_sounding_row_at_the_song_level(self, arranged: Project) -> None: + played = played_volumes(project_to_bitphase(arranged), ChannelIndex.SQUARE1) + song = song_row_volumes(arranged.song, ChannelName.PULSE1) * PLAYED_PASSES + + sounding = [(level, volume) for level, volume in zip(played, song) if volume is not None] + assert [level for level, _ in sounding] == [volume for _, volume in sounding] + + def test_the_triangle_sounds_on_every_row_the_song_sounds_it(self, arranged: Project) -> None: + played = played_volumes(project_to_bitphase(arranged), ChannelIndex.TRIANGLE) + song = song_row_volumes(arranged.song, ChannelName.TRIANGLE) * PLAYED_PASSES + + sounding = [(level, volume) for level, volume in zip(played, song) if volume is not None] + assert [level > 0 for level, _ in sounding] == [triangle_sounds_at(volume) for _, volume in sounding] + + def test_a_note_the_song_ends_quieter_than_writes_the_full_level(self, arranged: Project) -> None: + """The order returns to its first frame carrying the level its last frame set, so the song's + opening note is written at the full level although the first pass reaches it there anyway. + """ + row = project_to_bitphase(arranged).songs[0].patterns[0].channels[int(ChannelIndex.SQUARE1)].rows[0] + assert row.volume == FULL_VOLUME + + +class TestTheTriangleSoundsAboveHalfVolume(BaseTestSuite): + """The song sounds the triangle while a row asks for more than half volume, while Bitphase sounds + it at any pattern level above silence, so a quieter row writes the value that silences it. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + volume: int + expected: int + + test_cases: Tuple["TestTheTriangleSoundsAboveHalfVolume.TestCase", ...] = ( + TestCase(label="a level the triangle rests at", volume=QUIET_TRIANGLE_VOLUME, expected=VOLUME_OFF), + TestCase(label="a level the triangle sounds at", volume=LOUD_TRIANGLE_VOLUME, expected=LOUD_TRIANGLE_VOLUME), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_row_writes_the_triangle_gate( + self, + bass: Sample, + test_case: "TestTheTriangleSoundsAboveHalfVolume.TestCase", + ) -> None: + project = arranged_project( + (bass,), + {ChannelName.TRIANGLE: {0: rows_with((0, Row(command=NoteOn(voice_id=bass.id), volume=test_case.volume)))}}, + [{ChannelName.TRIANGLE: 0}], + ) + + row = project_to_bitphase(project).songs[0].patterns[0].channels[int(ChannelIndex.TRIANGLE)].rows[0] + + assert row.volume == test_case.expected + + +class TestALowTransposeKeepsTheSongsPitch: + """The song holds every tick's transposed pitch within 33-119, while Bitphase at concert pitch plays + any note below 33 at its longest period, a little flat of the lowest pitch. A transpose below the + range therefore raises the note only as far as the contour's highest step reaching the lowest pitch. + """ + + @staticmethod + def _transposed(sample: Sample, transpose: int) -> Tuple[BitphaseProject, BitphaseRow]: + project = arranged_project( + (sample,), + {ChannelName.PULSE1: {0: rows_with((0, Row(command=NoteOn(voice_id=sample.id), transpose=transpose)))}}, + [{ChannelName.PULSE1: 0}], + ) + document = project_to_bitphase(project) + return ( + document, + document.songs[0].patterns[0].channels[int(ChannelIndex.SQUARE1)].rows[0], + ) + + def test_a_flat_voice_transposed_below_the_range_writes_the_lowest_note(self) -> None: + _, row = self._transposed(pulse_sample("Low", LOW_PITCH, config=Config()), LOW_TRANSPOSE) + + assert cell_pitch(row.note) == MIN_PITCH + + def test_a_contour_reaching_into_the_range_keeps_every_tick_the_song_plays_there(self) -> None: + sample = contour_sample("Contour", CONTOUR_PITCHES, config=Config()) + document, row = self._transposed(sample, STRADDLING_TRANSPOSE) + + reference = voice_reference(sample, ChannelName.PULSE1) + steps = document.tables[0].rows + written = cell_pitch(row.note) - NOTE_INDEX_PITCH_OFFSET + tracker = [min(max(written + step, MIN_NOTE_INDEX), MAX_NOTE_INDEX) + NOTE_INDEX_PITCH_OFFSET for step in steps] + song = [transpose_pitch(reference + step, STRADDLING_TRANSPOSE) for step in steps] + + assert cell_pitch(row.note) == reference + STRADDLING_TRANSPOSE + assert [pitch for pitch, sung in zip(tracker, song) if sung > MIN_PITCH] == [ + sung for sung in song if sung > MIN_PITCH + ] + + def test_a_contour_lying_below_the_range_sounds_its_highest_step_at_the_lowest_pitch(self) -> None: + sample = contour_sample("Contour", CONTOUR_PITCHES, config=Config()) + document, row = self._transposed(sample, SUBMERGED_TRANSPOSE) + + assert cell_pitch(row.note) + max(document.tables[0].rows) == MIN_PITCH diff --git a/tests/unit/sampletones_core/formats/bitphase/test_transposes.py b/tests/unit/sampletones_core/formats/bitphase/test_transposes.py new file mode 100644 index 000000000..cecb71674 --- /dev/null +++ b/tests/unit/sampletones_core/formats/bitphase/test_transposes.py @@ -0,0 +1,437 @@ +from dataclasses import dataclass +from typing import Final, List, Optional, Tuple + +import pytest + +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.model.pattern import BitphaseRow, EffectCell +from sampletones_core.formats.bitphase.model.project import BitphaseProject +from sampletones_core.formats.bitphase.model.table import BitphaseTable +from sampletones_core.formats.bitphase.notes import pitch_to_note_index +from sampletones_core.formats.bitphase.specification.channels import ( + CHANNEL_LABELS, + CHANNEL_TO_INDEX, + ChannelIndex, +) +from sampletones_core.formats.bitphase.specification.effects import ( + MAX_ORNAMENT_POSITION, + NO_EFFECT_TABLE, + ORNAMENT_POSITION_DELAY, + EffectId, +) +from sampletones_core.formats.bitphase.specification.instruments import MAX_TABLE_ID, MIN_TABLE_ID +from sampletones_core.formats.bitphase.specification.patterns import ( + NO_INSTRUMENT_CHANGE, + NO_TABLE_CHANGE, + TABLE_COLUMN_OFFSET, + NoteName, +) +from sampletones_core.project.patterns.row import Row +from sampletones_core.project.project import Project +from sampletones_core.project.settings import ProjectSettings +from sampletones_core.project.voices.sample import Sample +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 +from tests.suite.transposes import ( + TRANSPOSED_ROWS_PER_PATTERN, + contour_sample, + flat_sample, + note, + one_channel_project, + rows_with, + sounded_pitches, +) + +UNIFORM_SETTINGS: Final[ProjectSettings] = ProjectSettings() +UNIFORM_ROW_TICKS: Final[int] = UNIFORM_SETTINGS.speed +GROOVE_SETTINGS: Final[ProjectSettings] = ProjectSettings(tempo=210) +CHANNEL: Final[ChannelName] = ChannelName.PULSE1 +FRAMES: Final[int] = 240 +LONG_FRAMES: Final[int] = 420 +RAISED: Final[int] = 2 +LOWERED: Final[int] = -3 +NOTE_TRANSPOSE: Final[int] = 5 +AFTER_RESET_TRANSPOSE: Final[int] = 1 +RAISED_ROW: Final[int] = 3 +LOWERED_ROW: Final[int] = 7 +RESET_ROW: Final[int] = 10 +AFTER_RESET_ROW: Final[int] = 13 +LATE_NOTE_ROW: Final[int] = 12 +PITCH_OFFSET: Final[int] = 24 +FLAT_PITCH: Final[int] = 40 +FLAT_FRAMES: Final[int] = 8 +SUBMERGED_TRANSPOSE: Final[int] = -20 +NOISE_PERIOD: Final[int] = 3 +NOISE_TRANSPOSE: Final[int] = -5 +LATE_FRAME: Final[int] = 3 +SHORT_ORDER: Final[Tuple[Optional[int], ...]] = (0, 1, 2) +LONG_ORDER: Final[Tuple[Optional[int], ...]] = (0, None, None, 1, 2) + + +def channel_row( + document: BitphaseProject, + frame: int, + row_index: int, + channel: ChannelName = CHANNEL, +) -> BitphaseRow: + return document.songs[0].patterns[frame].channels[int(CHANNEL_TO_INDEX[channel])].rows[row_index] + + +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 ornament(position: int) -> Tuple[Optional[EffectCell], ...]: + return ( + EffectCell( + effect=int(EffectId.ORNAMENT_POSITION), + delay=ORNAMENT_POSITION_DELAY, + parameter=position, + table_index=NO_EFFECT_TABLE, + ), + ) + + +@pytest.fixture(name="lead") +def lead_fixture() -> Sample: + return contour_sample(CHANNEL, FRAMES) + + +def moved_project( + voice: Sample, + *cells: Tuple[int, Row], + settings: ProjectSettings, +) -> Project: + return one_channel_project((voice,), CHANNEL, {0: rows_with(*cells)}, [0], settings=settings) + + +class TestTheCellsATransposeRowWrites: + """A table cell alone attaches a table while the instrument plays on, so a transpose row names the + note's table moved by the distance between the note its transpose would write and the note already + written, and places it at the step the note has reached. + """ + + @pytest.fixture(name="raised") + def raised_fixture(self, lead: Sample) -> BitphaseProject: + return project_to_bitphase( + moved_project(lead, (0, note(lead)), (RAISED_ROW, Row(transpose=RAISED)), settings=UNIFORM_SETTINGS) + ) + + def test_the_row_names_the_notes_table_moved_by_the_transpose(self, raised: BitphaseProject) -> None: + trigger = named_table(raised, channel_row(raised, 0, 0)) + moved = named_table(raised, channel_row(raised, 0, RAISED_ROW)) + + assert moved.rows == tuple(step + RAISED for step in trigger.rows) + assert moved.loop == trigger.loop + + def test_the_row_leaves_the_note_and_the_instrument_alone(self, raised: BitphaseProject) -> None: + """A note or an instrument cell restarts the instrument, so the row writes neither.""" + row = channel_row(raised, 0, RAISED_ROW) + + assert (row.note.name, row.instrument) == (int(NoteName.NONE), NO_INSTRUMENT_CHANGE) + + def test_the_row_places_the_table_at_the_step_the_note_reached(self, raised: BitphaseProject) -> None: + assert channel_row(raised, 0, RAISED_ROW).effects == ornament(RAISED_ROW * UNIFORM_ROW_TICKS) + + def test_the_moved_table_takes_an_id_above_the_slices(self, raised: BitphaseProject) -> None: + moved = named_table(raised, channel_row(raised, 0, RAISED_ROW)) + + assert moved.id == len(raised.instruments) + MIN_TABLE_ID + + def test_a_second_row_moves_the_note_from_where_it_was_written(self, lead: Sample) -> None: + """Every transpose is measured from the voice's reference, so each row's table is moved from the + note the note-on wrote, whatever an earlier row moved it to. + """ + document = project_to_bitphase( + moved_project( + lead, + (0, note(lead)), + (RAISED_ROW, Row(transpose=RAISED)), + (LOWERED_ROW, Row(transpose=LOWERED)), + settings=UNIFORM_SETTINGS, + ) + ) + + trigger = named_table(document, channel_row(document, 0, 0)) + moved = named_table(document, channel_row(document, 0, LOWERED_ROW)) + + assert moved.rows == tuple(step + LOWERED for step in trigger.rows) + assert channel_row(document, 0, LOWERED_ROW).effects == ornament(LOWERED_ROW * UNIFORM_ROW_TICKS) + + def test_the_shift_counts_from_the_transpose_the_note_started_at(self, lead: Sample) -> None: + document = project_to_bitphase( + moved_project( + lead, + (0, note(lead, NOTE_TRANSPOSE)), + (RAISED_ROW, Row(transpose=RAISED)), + settings=UNIFORM_SETTINGS, + ) + ) + + trigger = named_table(document, channel_row(document, 0, 0)) + moved = named_table(document, channel_row(document, 0, RAISED_ROW)) + + assert moved.rows == tuple(step + RAISED - NOTE_TRANSPOSE for step in trigger.rows) + + def test_a_note_on_starts_from_its_own_table_again(self, lead: Sample) -> None: + document = project_to_bitphase( + moved_project( + lead, + (0, note(lead)), + (RAISED_ROW, Row(transpose=RAISED)), + (RESET_ROW, note(lead)), + (AFTER_RESET_ROW, Row(transpose=LOWERED)), + settings=UNIFORM_SETTINGS, + ) + ) + + trigger = channel_row(document, 0, 0) + after = channel_row(document, 0, AFTER_RESET_ROW) + + assert channel_row(document, 0, RESET_ROW).table == trigger.table + assert named_table(document, after).rows == tuple( + step + LOWERED for step in named_table(document, trigger).rows + ) + assert after.effects == ornament((AFTER_RESET_ROW - RESET_ROW) * UNIFORM_ROW_TICKS) + + def test_a_row_returning_to_the_notes_transpose_names_the_notes_own_table(self, lead: Sample) -> None: + document = project_to_bitphase( + moved_project( + lead, + (0, note(lead)), + (RAISED_ROW, Row(transpose=RAISED)), + (LOWERED_ROW, Row(transpose=0)), + settings=UNIFORM_SETTINGS, + ) + ) + + assert channel_row(document, 0, LOWERED_ROW).table == channel_row(document, 0, 0).table + + def test_a_row_restating_the_transpose_in_force_writes_no_table(self, lead: Sample) -> None: + document = project_to_bitphase( + moved_project( + lead, + (0, note(lead)), + (RAISED_ROW, Row(transpose=RAISED)), + (LOWERED_ROW, Row(transpose=RAISED)), + settings=UNIFORM_SETTINGS, + ) + ) + + assert channel_row(document, 0, LOWERED_ROW).table == NO_TABLE_CHANGE + + def test_rows_moving_a_slice_alike_share_one_table(self, lead: Sample) -> None: + document = project_to_bitphase( + moved_project( + lead, + (0, note(lead)), + (RAISED_ROW, Row(transpose=RAISED)), + (RESET_ROW, note(lead)), + (AFTER_RESET_ROW, Row(transpose=RAISED)), + settings=UNIFORM_SETTINGS, + ) + ) + + assert channel_row(document, 0, AFTER_RESET_ROW).table == channel_row(document, 0, RAISED_ROW).table + + def test_a_transpose_below_the_range_moves_the_note_where_a_note_on_would_write_it(self) -> None: + """A note-on at the new transpose writes its note by the low-pitch rule, so the moved table + reaches the note that rule writes, and a flat slice sounds the lowest pitch the song plays. + """ + flat = flat_sample(CHANNEL, FLAT_PITCH, FLAT_FRAMES) + document = project_to_bitphase( + moved_project( + flat, + (0, note(flat)), + (RAISED_ROW, Row(transpose=SUBMERGED_TRANSPOSE)), + settings=UNIFORM_SETTINGS, + ) + ) + + moved = named_table(document, channel_row(document, 0, RAISED_ROW)) + + assert moved.rows == (pitch_to_note_index(MIN_PITCH) - pitch_to_note_index(FLAT_PITCH),) + + +class TestTheStepANoteHasReached: + """A note's table advances a step every tick from the note on, so the step a transpose row places + its table at counts the ticks every row since the note lasts, across the frames between them. + """ + + 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) + ) + + assert channel_row(document, 0, RAISED_ROW).effects == ornament(sum(groove_ticks(document)[: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.""" + project = one_channel_project( + (lead,), + CHANNEL, + {0: rows_with((LATE_NOTE_ROW, note(lead))), 1: rows_with((RAISED_ROW, Row(transpose=RAISED)))}, + [0, None, 1], + settings=GROOVE_SETTINGS, + ) + document = project_to_bitphase(project) + ticks = groove_ticks(document) + + elapsed = sum(ticks[LATE_NOTE_ROW:]) + sum(ticks) + sum(ticks[:RAISED_ROW]) + + assert channel_row(document, 2, RAISED_ROW).effects == ornament(elapsed) + + def test_a_step_past_the_ornament_position_names_a_table_opening_on_it(self) -> None: + long = contour_sample(CHANNEL, LONG_FRAMES) + project = one_channel_project( + (long,), + CHANNEL, + {0: rows_with((0, note(long))), 1: rows_with((0, Row(transpose=RAISED)))}, + [0, None, None, 1], + settings=UNIFORM_SETTINGS, + ) + document = project_to_bitphase(project) + reached = LATE_FRAME * TRANSPOSED_ROWS_PER_PATTERN * UNIFORM_ROW_TICKS + + trigger = named_table(document, channel_row(document, 0, 0)) + row = channel_row(document, LATE_FRAME, 0) + opening = named_table(document, row) + + assert reached > MAX_ORNAMENT_POSITION + assert row.effects == (None,) + assert [opening.rows[opening.position_at(tick)] for tick in range(LONG_FRAMES)] == [ + trigger.rows[trigger.position_at(reached + tick)] + RAISED for tick in range(LONG_FRAMES) + ] + + +class TestTheTablesADocumentHolds: + """The table column names one base-36 digit, so a document whose transpose rows move its notes to + more tables than it can name is refused. + """ + + @staticmethod + def _spread(transposes: int) -> Project: + """A flat slice moved by ``transposes`` distinct transposes, one per row after its note.""" + flat = flat_sample(CHANNEL, FLAT_PITCH, FLAT_FRAMES) + rows_per_frame = TRANSPOSED_ROWS_PER_PATTERN - 1 + patterns = { + frame: rows_with( + (0, note(flat)), + *( + (row_index, Row(transpose=frame * rows_per_frame + row_index)) + for row_index in range(1, TRANSPOSED_ROWS_PER_PATTERN) + if frame * rows_per_frame + row_index <= transposes + ), + ) + for frame in range((transposes + rows_per_frame - 1) // rows_per_frame) + } + return one_channel_project((flat,), CHANNEL, patterns, list(patterns), settings=UNIFORM_SETTINGS) + + def test_a_document_filling_the_table_column_is_written(self) -> None: + room = MAX_TABLE_ID - MIN_TABLE_ID + document = project_to_bitphase(self._spread(room)) + + assert max(table.id for table in document.tables) == MAX_TABLE_ID + + def test_a_document_needing_more_tables_is_refused(self) -> None: + room = MAX_TABLE_ID - MIN_TABLE_ID + with pytest.raises(ValueError, match="tables"): + project_to_bitphase(self._spread(room + 1)) + + +class TestANoiseTransposeWalksThePeriods: + """A noise transpose walks the period around the sixteen the channel has, and the moved table takes + whatever note the noise mapping writes for the new period, so it follows that mapping wherever the + note index sits. + """ + + def test_the_moved_table_sounds_the_walked_period(self) -> None: + drum = flat_sample(ChannelName.NOISE, NOISE_PERIOD, FLAT_FRAMES) + project = one_channel_project( + (drum,), + ChannelName.NOISE, + {0: rows_with((0, note(drum)), (RAISED_ROW, Row(transpose=NOISE_TRANSPOSE)))}, + [0], + settings=UNIFORM_SETTINGS, + ) + document = project_to_bitphase(project) + + trigger = channel_row(document, 0, 0, ChannelName.NOISE) + moved = named_table(document, channel_row(document, 0, RAISED_ROW, ChannelName.NOISE)) + index = note_value(trigger.note) + moved.rows[0] # type: ignore[arg-type] + + assert noise_register(index) == MAX_PERIOD - (NOISE_PERIOD + NOISE_TRANSPOSE) % NUM_PERIODS + + +def replayed(document: BitphaseProject, channel_name: ChannelName) -> List[Optional[int]]: + """What Bitphase sounds on one channel each tick: the pitch, or on noise the period register.""" + loaded = parse_btp(project_to_bytes(document), list(CHANNEL_LABELS)) + notes = played_notes(loaded, int(CHANNEL_TO_INDEX[channel_name])) + if channel_name == ChannelName.NOISE: + return [None if index is None else noise_register(index) for index in notes] + + return [None if index is None else index + PITCH_OFFSET for index in notes] + + +class TestATransposedSongSoundsTheSongsPitch(BaseTestSuite): + """Played the way Bitphase reads its rows, a song whose transpose rows move a sounding note, cross + frames under a groove, and meet a note-on starting over sounds the pitch the song's walk sounds on + every tick the song sounds. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + channel: ChannelName + frames: int + order: Tuple[Optional[int], ...] + + test_cases: Tuple["TestATransposedSongSoundsTheSongsPitch.TestCase", ...] = ( + TestCase(label="pulse", channel=ChannelName.PULSE1, frames=FRAMES, order=SHORT_ORDER), + TestCase(label="triangle", channel=ChannelName.TRIANGLE, frames=FRAMES, order=SHORT_ORDER), + TestCase(label="noise", channel=ChannelName.NOISE, frames=FRAMES, order=SHORT_ORDER), + TestCase(label="pulse_past_the_ornament", channel=ChannelName.PULSE1, frames=LONG_FRAMES, order=LONG_ORDER), + TestCase( + label="triangle_past_the_ornament", channel=ChannelName.TRIANGLE, frames=LONG_FRAMES, order=LONG_ORDER + ), + TestCase(label="noise_past_the_ornament", channel=ChannelName.NOISE, frames=LONG_FRAMES, order=LONG_ORDER), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_every_sounding_tick_plays_the_songs_pitch( + self, + test_case: "TestATransposedSongSoundsTheSongsPitch.TestCase", + ) -> None: + voice = contour_sample(test_case.channel, test_case.frames) + patterns = { + 0: rows_with((0, note(voice)), (RAISED_ROW, Row(transpose=RAISED))), + 1: rows_with( + (2, Row(transpose=LOWERED)), + (RESET_ROW, note(voice)), + (AFTER_RESET_ROW, Row(transpose=AFTER_RESET_TRANSPOSE)), + ), + 2: rows_with((RAISED_ROW, Row(transpose=LOWERED)), (LOWERED_ROW, Row(transpose=0))), + } + project = one_channel_project( + (voice,), + test_case.channel, + patterns, + list(test_case.order), + settings=GROOVE_SETTINGS, + ) + + sounded = sounded_pitches(project, test_case.channel) + played = replayed(project_to_bitphase(project), test_case.channel) + + assert len(played) == len(sounded) + assert [tick for tick, pitch in enumerate(sounded) if pitch is not None and played[tick] != pitch] == [] diff --git a/tests/unit/sampletones_core/formats/famitracker/conftest.py b/tests/unit/sampletones_core/formats/famitracker/conftest.py index 6350af755..40919c103 100644 --- a/tests/unit/sampletones_core/formats/famitracker/conftest.py +++ b/tests/unit/sampletones_core/formats/famitracker/conftest.py @@ -54,6 +54,23 @@ def pulse_sample(name: str, pitch: int) -> Sample: ) +def contour_sample(name: str, pitches: Sequence[int]) -> Sample: + """A pulse sample sounding each pitch for one frame, so its arpeggio moves the note between them.""" + instructions = [PulseInstruction(on=True, pitch=pitch, volume=15, duty_cycle=0) for pitch in pitches] + return Sample( + name=name, + reconstruction=build_reconstruction({ChannelName.PULSE1: instructions}), + ) + + +def triangle_sample(name: str, pitch: int) -> Sample: + instructions = [TriangleInstruction(on=True, pitch=pitch)] + return Sample( + name=name, + reconstruction=build_reconstruction({ChannelName.TRIANGLE: instructions}), + ) + + def noise_sample(name: str, period: int) -> Sample: instructions = [NoiseInstruction(on=True, period=period, volume=15, short=False)] return Sample( 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 8de03b6cc..3d909c8ec 100644 --- a/tests/unit/sampletones_core/formats/famitracker/sequences/test_features.py +++ b/tests/unit/sampletones_core/formats/famitracker/sequences/test_features.py @@ -11,6 +11,7 @@ stored_length, ) from sampletones_core.formats.famitracker.specification.sequences import ( + BEND_SEQUENCE_KINDS, LOOP_FROM_START, MAX_SEQUENCE_ITEMS, NO_LOOP_POINT, @@ -63,61 +64,68 @@ class TestTheArpeggioThatPinsABend: """ def test_a_bend_without_an_arpeggio_gains_a_repeating_one(self) -> None: - arpeggio = features_to_instrument_sequences(build([15, 0], [], pitch=[3, -3]))[SequenceKind.ARPEGGIO] + arpeggio = features_to_instrument_sequences(build([15, 0], [], pitch=[3, -3]), repitched=False)[ + SequenceKind.ARPEGGIO + ] assert arpeggio.enabled is True 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]))[SequenceKind.ARPEGGIO] + arpeggio = features_to_instrument_sequences(build([15, 0], [4, 7], pitch=[1, 2, 3, 4]), repitched=False)[ + SequenceKind.ARPEGGIO + ] assert arpeggio.items == (4, 7, 7, 7) def test_an_arpeggio_that_repeats_is_left_as_it_stands(self) -> None: sequences = features_to_instrument_sequences( - build([15, 0], [0, 5], pitch=[1, 2, 3, 4], loop_point=LOOP_FROM_START) + build([15, 0], [0, 5], pitch=[1, 2, 3, 4], loop_point=LOOP_FROM_START), + repitched=False, ) assert sequences[SequenceKind.ARPEGGIO].items == (0, 5) 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]))[SequenceKind.ARPEGGIO] + arpeggio = features_to_instrument_sequences(build([15, 0], [4, 7, 9], pitch=[1, 2]), repitched=False)[ + SequenceKind.ARPEGGIO + ] assert arpeggio.items == (4, 7, 9) def test_an_instrument_writing_no_bend_gains_no_arpeggio(self) -> None: - arpeggio = features_to_instrument_sequences(build([15, 0], []))[SequenceKind.ARPEGGIO] + arpeggio = features_to_instrument_sequences(build([15, 0], []), repitched=False)[SequenceKind.ARPEGGIO] assert arpeggio.enabled is False class TestFeaturesToInstrumentSequences: def test_all_five_kinds_present(self) -> None: - sequences = features_to_instrument_sequences(build([15, 0], [0])) + sequences = features_to_instrument_sequences(build([15, 0], [0]), repitched=False) assert set(sequences) == set(SequenceKind) def test_populated_dimension_is_enabled_with_items(self) -> None: - volume = features_to_instrument_sequences(build([15, 12, 0], []))[SequenceKind.VOLUME] + volume = features_to_instrument_sequences(build([15, 12, 0], []), repitched=False)[SequenceKind.VOLUME] assert volume.enabled is True assert volume.items == (15, 12, 0) def test_missing_dimension_is_disabled(self) -> None: - sequences = features_to_instrument_sequences(build([15, 0], [])) + sequences = features_to_instrument_sequences(build([15, 0], []), repitched=False) assert sequences[SequenceKind.PITCH].enabled is False assert sequences[SequenceKind.PITCH].items == () assert sequences[SequenceKind.ARPEGGIO].enabled is False def test_items_are_python_ints(self) -> None: - sequences = features_to_instrument_sequences(build([15, 8], [-3])) + sequences = features_to_instrument_sequences(build([15, 8], [-3]), repitched=False) assert all(isinstance(item, int) for item in sequences[SequenceKind.VOLUME].items) assert all(isinstance(item, int) for item in sequences[SequenceKind.ARPEGGIO].items) class TestTheLoopPointEachSequenceCarries: def test_a_dimension_carries_the_point_it_states(self) -> None: - sequences = features_to_instrument_sequences(build([15, 10], [0, 2], loop_point=0)) + sequences = features_to_instrument_sequences(build([15, 10], [0, 2], loop_point=0), repitched=False) assert sequences[SequenceKind.VOLUME].loop_point == LOOP_FROM_START assert sequences[SequenceKind.ARPEGGIO].loop_point == LOOP_FROM_START @@ -132,25 +140,25 @@ def test_each_dimension_repeats_from_its_own_item(self) -> None: duty_cycle=envelope([1, 1]), ) - sequences = features_to_instrument_sequences(features) + sequences = features_to_instrument_sequences(features, repitched=False) assert sequences[SequenceKind.VOLUME].loop_point == 2 assert sequences[SequenceKind.ARPEGGIO].loop_point == 0 assert sequences[SequenceKind.DUTY].loop_point == NO_LOOP_POINT def test_a_dimension_the_instrument_leaves_out_states_no_point(self) -> None: - sequences = features_to_instrument_sequences(build([15, 0], [], loop_point=0)) + sequences = features_to_instrument_sequences(build([15, 0], [], loop_point=0), repitched=False) assert sequences[SequenceKind.PITCH].loop_point == NO_LOOP_POINT def test_a_dimension_that_halts_states_no_point(self) -> None: - sequences = features_to_instrument_sequences(build([15, 10], [0])) + sequences = features_to_instrument_sequences(build([15, 10], [0]), repitched=False) assert sequences[SequenceKind.VOLUME].loop_point == NO_LOOP_POINT class TestSequenceLengths: def test_every_dimension_stands_at_the_length_it_was_written(self) -> None: """A halted sequence holds its final value, so a shorter dimension governs the rest itself.""" - sequences = features_to_instrument_sequences(build([15, 12, 9, 0], [0, 2, 4], duty_cycle=[1])) + sequences = features_to_instrument_sequences(build([15, 12, 9, 0], [0, 2, 4], duty_cycle=[1]), repitched=False) assert sequences[SequenceKind.VOLUME].items == (15, 12, 9, 0) assert sequences[SequenceKind.ARPEGGIO].items == (0, 2, 4) assert sequences[SequenceKind.DUTY].items == (1,) @@ -158,34 +166,37 @@ def test_every_dimension_stands_at_the_length_it_was_written(self) -> None: def test_circling_costs_a_dimension_none_of_its_items(self) -> None: """Each dimension repeats on its own period, so a loop leaves every length as written.""" sequences = features_to_instrument_sequences( - build([15, 12, 9, 0], [0, 2, 4], duty_cycle=[1, 1, 2], loop_point=0) + build([15, 12, 9, 0], [0, 2, 4], duty_cycle=[1, 1, 2], loop_point=0), + repitched=False, ) assert sequences[SequenceKind.VOLUME].items == (15, 12, 9, 0) assert sequences[SequenceKind.ARPEGGIO].items == (0, 2, 4) assert sequences[SequenceKind.DUTY].items == (1, 1, 2) def test_disabled_dimensions_stay_empty(self) -> None: - sequences = features_to_instrument_sequences(build([15, 12, 0], [])) + sequences = features_to_instrument_sequences(build([15, 12, 0], []), repitched=False) assert sequences[SequenceKind.ARPEGGIO].items == () assert sequences[SequenceKind.PITCH].items == () def test_an_empty_envelope_differs_from_one_holding_a_single_zero(self) -> None: """An empty dimension leaves its sequence disabled; a single zero is a value the instrument sets.""" - cleared = features_to_instrument_sequences(build([15, 0], [])) - zeroed = features_to_instrument_sequences(build([15, 0], [0])) + cleared = features_to_instrument_sequences(build([15, 0], []), repitched=False) + zeroed = features_to_instrument_sequences(build([15, 0], [0]), repitched=False) assert cleared[SequenceKind.ARPEGGIO].enabled is False assert zeroed[SequenceKind.ARPEGGIO].enabled is True assert zeroed[SequenceKind.ARPEGGIO].items == (0,) def test_all_dimensions_empty_stays_empty(self) -> None: - sequences = features_to_instrument_sequences(build([], [], loop_point=0)) + sequences = features_to_instrument_sequences(build([], [], loop_point=0), repitched=False) assert all(not sequence.enabled for sequence in sequences.values()) def test_an_over_long_envelope_builds_sequences_famitracker_accepts(self) -> None: length = MAX_SEQUENCE_ITEMS + 48 - sequences = features_to_instrument_sequences(build([index % 16 for index in range(length)], [0] * length)) + sequences = features_to_instrument_sequences( + build([index % 16 for index in range(length)], [0] * length), repitched=False + ) assert all(len(sequence.items) <= MAX_SEQUENCE_ITEMS for sequence in sequences.values()) @@ -198,38 +209,38 @@ class TestTheReleaseAnExportKeeps: """ def test_a_released_dimension_at_the_limit_is_written_whole(self) -> None: - sequences = features_to_instrument_sequences(build(released(MAX_SEQUENCE_ITEMS), [])) + sequences = features_to_instrument_sequences(build(released(MAX_SEQUENCE_ITEMS), []), repitched=False) volume = sequences[SequenceKind.VOLUME] assert len(volume.items) == MAX_SEQUENCE_ITEMS assert volume.items[-1] == RELEASE def test_a_released_dimension_past_the_limit_still_ends_at_its_release(self) -> None: - sequences = features_to_instrument_sequences(build(released(MAX_SEQUENCE_ITEMS + 1), [])) + sequences = features_to_instrument_sequences(build(released(MAX_SEQUENCE_ITEMS + 1), []), repitched=False) volume = sequences[SequenceKind.VOLUME] assert len(volume.items) == MAX_SEQUENCE_ITEMS assert volume.items[-1] == RELEASE def test_the_release_displaces_the_last_item_that_would_not_fit(self) -> None: source = released(MAX_SEQUENCE_ITEMS + 1) - volume = features_to_instrument_sequences(build(source, []))[SequenceKind.VOLUME] + volume = features_to_instrument_sequences(build(source, []), repitched=False)[SequenceKind.VOLUME] assert volume.items == tuple(source[: MAX_SEQUENCE_ITEMS - 1]) + (RELEASE,) def test_a_dimension_that_goes_on_sounding_keeps_its_opening_items(self) -> None: source = sounding(MAX_SEQUENCE_ITEMS + 8) - volume = features_to_instrument_sequences(build(source, []))[SequenceKind.VOLUME] + volume = features_to_instrument_sequences(build(source, []), repitched=False)[SequenceKind.VOLUME] assert volume.items == tuple(source[:MAX_SEQUENCE_ITEMS]) def test_a_circling_dimension_reads_its_final_silence_as_part_of_the_cycle(self) -> None: """A dimension repeating from a point never halts, so its last item releases nothing.""" source = released(MAX_SEQUENCE_ITEMS + 1) - volume = features_to_instrument_sequences(build(source, [], loop_point=0))[SequenceKind.VOLUME] + volume = features_to_instrument_sequences(build(source, [], loop_point=0), repitched=False)[SequenceKind.VOLUME] assert volume.items == tuple(source[:MAX_SEQUENCE_ITEMS]) def test_a_dimension_other_than_volume_keeps_its_opening_items(self) -> None: """Only a volume dimension releases a note; the rest are read at whatever they last stated.""" source = [index % 8 for index in range(MAX_SEQUENCE_ITEMS + 8)] source[-1] = 0 - arpeggio = features_to_instrument_sequences(build(released(4), source))[SequenceKind.ARPEGGIO] + arpeggio = features_to_instrument_sequences(build(released(4), source), repitched=False)[SequenceKind.ARPEGGIO] assert arpeggio.items == tuple(source[:MAX_SEQUENCE_ITEMS]) @@ -257,3 +268,45 @@ def test_features_past_the_limit_report_both_counts(self) -> None: assert truncation.source_frames == source_frames assert truncation.frames == MAX_SEQUENCE_ITEMS assert truncation.instruments == 1 + + +class TestTheSequencesANoteSlideReaches: + """A note slide moves the channel's note, and only an arpeggio still running reloads the period from + it every tick, so an instrument a slide reaches keeps its arpeggio running for as long as the note + sounds. The bend is added to the period the arpeggio reloads and a halted bend adds nothing, so the + bend keeps running too, each on the last item it plays. + """ + + def test_an_arpeggio_playing_once_circles_on_its_last_item(self) -> None: + arpeggio = features_to_instrument_sequences(build([15, 0], [4, 7, 9]), repitched=True)[SequenceKind.ARPEGGIO] + + assert arpeggio.items == (4, 7, 9) + assert arpeggio.loop_point == len(arpeggio.items) - 1 + + def test_an_arpeggio_that_repeats_is_left_as_it_stands(self) -> None: + arpeggio = features_to_instrument_sequences( + build([15, 0], [4, 7, 9], loop_point=LOOP_FROM_START), + repitched=True, + )[SequenceKind.ARPEGGIO] + + assert arpeggio.loop_point == LOOP_FROM_START + + def test_an_instrument_writing_no_arpeggio_takes_one_running_at_its_own_note(self) -> None: + arpeggio = features_to_instrument_sequences(build([15, 0], []), repitched=True)[SequenceKind.ARPEGGIO] + + assert (arpeggio.items, arpeggio.loop_point) == ((0,), LOOP_FROM_START) + + def test_a_bend_playing_once_circles_on_the_offset_it_ends_on(self) -> None: + sequences = features_to_instrument_sequences(build([15, 0], [0, 2], pitch=[1, 2, 3]), repitched=True) + + assert (sequences[SequenceKind.PITCH].items, sequences[SequenceKind.PITCH].loop_point) == ((1, 2, 3), 2) + + def test_a_bend_the_channel_governs_stays_disabled(self) -> None: + sequences = features_to_instrument_sequences(build([15, 0], [0, 2]), repitched=True) + + assert not any(sequences[kind].enabled for kind in BEND_SEQUENCE_KINDS) + + def test_an_instrument_no_slide_reaches_keeps_its_arpeggio_halting(self) -> None: + arpeggio = features_to_instrument_sequences(build([15, 0], [4, 7, 9]), repitched=False)[SequenceKind.ARPEGGIO] + + assert arpeggio.loop_point == NO_LOOP_POINT diff --git a/tests/unit/sampletones_core/formats/famitracker/test_builder.py b/tests/unit/sampletones_core/formats/famitracker/test_builder.py index cab64ca40..627ff3f05 100644 --- a/tests/unit/sampletones_core/formats/famitracker/test_builder.py +++ b/tests/unit/sampletones_core/formats/famitracker/test_builder.py @@ -1,13 +1,19 @@ +from dataclasses import dataclass +from typing import Dict, Final, List, Mapping, Optional, Sequence, Tuple + import numpy as np import pytest from sampletones_core.constants.enums import ChannelName -from sampletones_core.exporters.skipped import SkippedRow +from sampletones_core.constants.general import MAX_VOLUME, MIN_PITCH +from sampletones_core.exporters.skipped import SkippedRow, SkipReason from sampletones_core.formats.famitracker.builder import ( build_instrument_table, build_module, project_to_module, ) +from sampletones_core.formats.famitracker.model.module import FamiTrackerModule +from sampletones_core.formats.famitracker.model.pattern import RowCell from sampletones_core.formats.famitracker.specification.channels import ( CHANNEL_COUNT_2A03, ChannelId, @@ -21,7 +27,12 @@ Machine, ) from sampletones_core.formats.famitracker.specification.patterns import ( + EMPTY_EFFECT, + EMPTY_EFFECT_PARAM, EMPTY_INSTRUMENT, + EMPTY_VOLUME, + NOTE_RANGE, + PITCH_OCTAVE_OFFSET, NoteValue, ) from sampletones_core.formats.famitracker.specification.sequences import ( @@ -29,19 +40,56 @@ SequenceKind, ) from sampletones_core.instructions.implementation.pulse import PulseInstruction +from sampletones_core.performance.modifiers import triangle_sounds_at from sampletones_core.project.patterns.channel import Channel from sampletones_core.project.patterns.pattern import Pattern from sampletones_core.project.patterns.row import Row from sampletones_core.project.project import Project +from sampletones_core.project.settings import ProjectSettings +from sampletones_core.project.song import Song +from sampletones_core.project.voices.note_off import NoteOff from sampletones_core.project.voices.note_on import NoteOn from sampletones_core.project.voices.sample import Sample - -from .conftest import RECONSTRUCTION_LENGTH, ProjectFixture, build_reconstruction +from sampletones_core.project.voices.voice import voice_reference +from sampletones_core.structures import IdentifiedCollection +from sampletones_core.utils.frequencies import transpose_pitch +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase +from tests.suite.famitracker import FAMITRACKER_OPENING_VOLUME, column_volume +from tests.suite.performance import song_row_volumes + +from .conftest import ( + RECONSTRUCTION_LENGTH, + ProjectFixture, + build_reconstruction, + contour_sample, + pulse_sample, + triangle_sample, +) LEAD_PITCH = 60 OCTAVE = 12 CUSTOM_NES_FREQUENCY = 30 +ROWS_PER_PATTERN: Final[int] = 8 +BASS_PITCH: Final[int] = 48 +TRANSPOSE: Final[int] = 5 +QUIET_VOLUME: Final[int] = 4 +NOTE_OFF_VOLUME: Final[int] = 6 +CLOSING_VOLUME: Final[int] = 9 +FRAME_CLOSING_VOLUME: Final[int] = 2 +QUIET_TRIANGLE_VOLUME: Final[int] = 5 +LOUD_TRIANGLE_VOLUME: Final[int] = 12 +RESTING_TRIANGLE_VOLUME: Final[int] = 7 +NOTE_TRIANGLE_VOLUME: Final[int] = 3 +SILENT_COLUMN: Final[int] = 0 +PLAYED_PASSES: Final[int] = 2 +LOW_PITCH: Final[int] = 36 +LOW_TRANSPOSE: Final[int] = -10 +CONTOUR_PITCHES: Final[Tuple[int, ...]] = (40, 50) +STRADDLING_TRANSPOSE: Final[int] = -14 +SUBMERGED_TRANSPOSE: Final[int] = -20 + class TestBuildInstrumentTable: def test_one_instrument_per_generator_slice(self, project_fixture: ProjectFixture) -> None: @@ -174,6 +222,12 @@ def test_empty_rows_are_dropped(self, project_fixture: ProjectFixture) -> None: pattern = self._pattern(module.track.patterns, ChannelId.SQUARE1, 0) assert {row.row_number for row in pattern.rows} == {0, 2, 4} + def test_every_row_leaves_its_effect_columns_empty(self, project_fixture: ProjectFixture) -> None: + """A `Vxx` is what moves FamiTracker's default duty, so every note there starts on duty 0.""" + module = project_to_module(project_fixture.project) + effects = {effect for pattern in module.track.patterns for row in pattern.rows for effect in row.effects} + assert effects == {(EMPTY_EFFECT, EMPTY_EFFECT_PARAM)} + def test_noise_row_uses_period_note(self, project_fixture: ProjectFixture) -> None: module = project_to_module(project_fixture.project) pattern = self._pattern(module.track.patterns, ChannelId.NOISE, 0) @@ -220,6 +274,7 @@ def test_the_row_is_reported_where_the_tracker_shows_it(self, project_fixture: P channel=ChannelName.PULSE2, order_position=0, row_index=self.DRUM_ROW, + reason=SkipReason.NO_INSTRUMENT, ), ) @@ -265,3 +320,250 @@ def test_none_slots_map_to_a_reserved_pattern_index(self, project_fixture: Proje def test_dpcm_channel_is_always_empty(self, project_fixture: ProjectFixture) -> None: module = project_to_module(project_fixture.project) assert all(frame[ChannelId.DPCM] == 0 for frame in module.track.order) + + +def rows_with(*cells: Tuple[int, Row]) -> List[Row]: + """A pattern's rows, blank apart from the ones given by their index.""" + rows = [Row() for _ in range(ROWS_PER_PATTERN)] + for row_index, row in cells: + rows[row_index] = row + + return rows + + +def arranged_project( + voices: Sequence[Sample], + patterns: Mapping[ChannelName, Mapping[int, List[Row]]], + order: List[Dict[ChannelName, Optional[int]]], +) -> Project: + """A project playing ``voices`` through the channel patterns ``order`` names.""" + pool: IdentifiedCollection[Sample] = IdentifiedCollection() + for voice in voices: + pool.append(voice) + + channels = { + channel_name: Channel( + name=channel_name, + patterns={index: Pattern(rows=rows) for index, rows in patterns.get(channel_name, {}).items()}, + ) + for channel_name in ChannelName.items() + } + project = Project.create(title="Rows", author="Tester", settings=ProjectSettings()) + project.voices = pool + project.song = Song(rows_per_pattern=ROWS_PER_PATTERN, order=order, channels=channels) + return project + + +def played_volumes(module: FamiTrackerModule, channel: ChannelId) -> List[int]: + """The level FamiTracker plays one channel at on each row, the order played through and round again. + + FamiTracker wraps from the last frame to the first, keeping every channel's level, so a second + pass shows what the level the song ends on does to the notes it opens with. + """ + cells = { + (pattern.channel, pattern.index): {row.row_number: row for row in pattern.rows} + for pattern in module.track.patterns + } + level = FAMITRACKER_OPENING_VOLUME + levels: List[int] = [] + for _ in range(PLAYED_PASSES): + for frame in module.track.order: + pattern = cells.get((channel, frame[int(channel)]), {}) + for row_number in range(module.track.rows_per_pattern): + cell = pattern.get(row_number) + if cell is not None: + level = column_volume(level, cell.volume) + + levels.append(level) + + return levels + + +def pattern_cell(module: FamiTrackerModule, channel: ChannelId, index: int, row_number: int) -> RowCell: + pattern = next( + pattern for pattern in module.track.patterns if pattern.channel == channel and pattern.index == index + ) + return next(row for row in pattern.rows if row.row_number == row_number) + + +def cell_pitch(cell: RowCell) -> int: + """The pitch a pattern cell's note and octave name.""" + return (cell.octave + PITCH_OCTAVE_OFFSET) * NOTE_RANGE + cell.note - int(NoteValue.C) + + +@pytest.fixture(name="lead") +def lead_fixture() -> Sample: + return pulse_sample("lead", pitch=LEAD_PITCH) + + +@pytest.fixture(name="bass") +def bass_fixture() -> Sample: + return triangle_sample("bass", pitch=BASS_PITCH) + + +class TestTheLevelsAPlayedModuleCarries: + """The song starts a note stating no level at the full level and sounds the triangle only above + half volume, while FamiTracker carries the last level a cell wrote into every note and sounds the + triangle at any level above silence. The module therefore writes its volume column so that + FamiTracker, playing the order through and round again, plays each row the song sounds at the + song's own level, including in a pattern two frames reach at different levels. + """ + + @pytest.fixture(name="arranged") + def arranged_fixture(self, lead: Sample, bass: Sample) -> Project: + lead_note = Row(command=NoteOn(voice_id=lead.id)) + bass_note = Row(command=NoteOn(voice_id=bass.id)) + pulse = { + 0: rows_with( + (0, lead_note), + (1, Row(volume=QUIET_VOLUME)), + (2, Row(transpose=TRANSPOSE)), + (3, lead_note), + (4, Row(command=NoteOff(), volume=NOTE_OFF_VOLUME)), + (5, lead_note), + (6, Row(volume=CLOSING_VOLUME)), + ), + 1: rows_with( + (0, lead_note), + (3, Row(volume=FRAME_CLOSING_VOLUME)), + ), + } + triangle = { + 0: rows_with( + (0, bass_note), + (2, Row(volume=QUIET_TRIANGLE_VOLUME)), + (4, Row(volume=LOUD_TRIANGLE_VOLUME)), + (6, bass_note), + ), + 1: rows_with( + (0, Row(volume=RESTING_TRIANGLE_VOLUME)), + (2, bass_note), + (5, Row(command=NoteOn(voice_id=bass.id), volume=NOTE_TRIANGLE_VOLUME)), + ), + } + order: List[Dict[ChannelName, Optional[int]]] = [ + {ChannelName.PULSE1: 0, ChannelName.TRIANGLE: 0}, + {ChannelName.PULSE1: 1, ChannelName.TRIANGLE: 1}, + {ChannelName.PULSE1: None, ChannelName.TRIANGLE: 0}, + {ChannelName.PULSE1: 0, ChannelName.TRIANGLE: None}, + ] + return arranged_project( + (lead, bass), + {ChannelName.PULSE1: pulse, ChannelName.TRIANGLE: triangle}, + order, + ) + + def test_the_pulse_plays_every_sounding_row_at_the_song_level(self, arranged: Project) -> None: + played = played_volumes(project_to_module(arranged), ChannelId.SQUARE1) + song = song_row_volumes(arranged.song, ChannelName.PULSE1) * PLAYED_PASSES + + sounding = [(level, volume) for level, volume in zip(played, song) if volume is not None] + assert [level for level, _ in sounding] == [volume for _, volume in sounding] + + def test_the_triangle_sounds_on_every_row_the_song_sounds_it(self, arranged: Project) -> None: + played = played_volumes(project_to_module(arranged), ChannelId.TRIANGLE) + song = song_row_volumes(arranged.song, ChannelName.TRIANGLE) * PLAYED_PASSES + + sounding = [(level, volume) for level, volume in zip(played, song) if volume is not None] + assert [level > 0 for level, _ in sounding] == [triangle_sounds_at(volume) for _, volume in sounding] + + def test_a_note_after_a_quieter_row_starts_at_the_full_level(self, arranged: Project) -> None: + cell = pattern_cell(project_to_module(arranged), ChannelId.SQUARE1, 0, 3) + assert cell.volume == MAX_VOLUME + + def test_a_note_the_channel_reaches_at_the_full_level_leaves_the_column_alone(self, lead: Sample) -> None: + lead_note = Row(command=NoteOn(voice_id=lead.id)) + project = arranged_project( + (lead,), + {ChannelName.PULSE1: {0: rows_with((0, lead_note), (4, lead_note))}}, + [{ChannelName.PULSE1: 0}, {ChannelName.PULSE1: 0}], + ) + + module = project_to_module(project) + + assert [pattern_cell(module, ChannelId.SQUARE1, 0, row).volume for row in (0, 4)] == [EMPTY_VOLUME] * 2 + + def test_a_pattern_one_frame_reaches_quieter_writes_the_full_level(self, arranged: Project) -> None: + """The module stores the pulse's first pattern once for the two frames that play it, and the + second of them reaches its opening note at the level the frame before it ends on. + """ + cell = pattern_cell(project_to_module(arranged), ChannelId.SQUARE1, 0, 0) + assert cell.volume == MAX_VOLUME + + +class TestTheTriangleSoundsAboveHalfVolume(BaseTestSuite): + """The song sounds the triangle while a row asks for more than half volume, while FamiTracker + sounds it at any column level above zero, so a quieter row writes the level that silences it. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + volume: int + expected: int + + test_cases: Tuple["TestTheTriangleSoundsAboveHalfVolume.TestCase", ...] = ( + TestCase(label="a level the triangle rests at", volume=QUIET_TRIANGLE_VOLUME, expected=SILENT_COLUMN), + TestCase(label="a level the triangle sounds at", volume=LOUD_TRIANGLE_VOLUME, expected=LOUD_TRIANGLE_VOLUME), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_row_writes_the_triangle_gate( + self, + bass: Sample, + test_case: "TestTheTriangleSoundsAboveHalfVolume.TestCase", + ) -> None: + project = arranged_project( + (bass,), + {ChannelName.TRIANGLE: {0: rows_with((0, Row(command=NoteOn(voice_id=bass.id), volume=test_case.volume)))}}, + [{ChannelName.TRIANGLE: 0}], + ) + + assert pattern_cell(project_to_module(project), ChannelId.TRIANGLE, 0, 0).volume == test_case.expected + + +class TestALowTransposeKeepsTheSongsPitch: + """The song holds every tick's transposed pitch within 33-119, while FamiTracker plays any note + below A-0 at its longest period, a little flat of the lowest pitch. A transpose below the range + therefore raises the note only as far as the arpeggio's highest step reaching the lowest pitch. + """ + + @staticmethod + def _transposed(sample: Sample, transpose: int) -> Tuple[FamiTrackerModule, RowCell]: + project = arranged_project( + (sample,), + {ChannelName.PULSE1: {0: rows_with((0, Row(command=NoteOn(voice_id=sample.id), transpose=transpose)))}}, + [{ChannelName.PULSE1: 0}], + ) + module = project_to_module(project) + return ( + module, + pattern_cell(module, ChannelId.SQUARE1, 0, 0), + ) + + @staticmethod + def _arpeggio(module: FamiTrackerModule) -> Tuple[int, ...]: + return module.instruments[0].sequences[SequenceKind.ARPEGGIO].items + + def test_a_flat_voice_transposed_below_the_range_writes_the_lowest_note(self) -> None: + _, cell = self._transposed(pulse_sample("low", pitch=LOW_PITCH), LOW_TRANSPOSE) + + assert cell_pitch(cell) == MIN_PITCH + + def test_a_contour_reaching_into_the_range_keeps_every_tick_the_song_plays_there(self) -> None: + sample = contour_sample("contour", CONTOUR_PITCHES) + module, cell = self._transposed(sample, STRADDLING_TRANSPOSE) + + reference = voice_reference(sample, ChannelName.PULSE1) + steps = self._arpeggio(module) + tracker = [cell_pitch(cell) + step for step in steps] + song = [transpose_pitch(reference + step, STRADDLING_TRANSPOSE) for step in steps] + + assert cell_pitch(cell) == reference + STRADDLING_TRANSPOSE + assert [pitch for pitch, sung in zip(tracker, song) if sung > MIN_PITCH] == [ + sung for sung in song if sung > MIN_PITCH + ] + + def test_a_contour_lying_below_the_range_sounds_its_highest_step_at_the_lowest_pitch(self) -> None: + module, cell = self._transposed(contour_sample("contour", CONTOUR_PITCHES), SUBMERGED_TRANSPOSE) + + assert cell_pitch(cell) + max(self._arpeggio(module)) == MIN_PITCH diff --git a/tests/unit/sampletones_core/formats/famitracker/test_footprint.py b/tests/unit/sampletones_core/formats/famitracker/test_footprint.py index 1ee9d2ff0..5cd3c8841 100644 --- a/tests/unit/sampletones_core/formats/famitracker/test_footprint.py +++ b/tests/unit/sampletones_core/formats/famitracker/test_footprint.py @@ -120,7 +120,7 @@ def test_both_regions_are_measured_from_the_populated_sequences( @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) def test_the_built_instrument_measures_the_same(self, test_case: TestCase) -> None: """Both entry points measure one export, so a slice reads the same either way.""" - instrument = build_instrument(0, test_case.label, test_case.features) + instrument = build_instrument(0, test_case.label, test_case.features, repitched=False) assert instrument_footprint(instrument) == test_case.expected @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) diff --git a/tests/unit/sampletones_core/formats/famitracker/test_fti.py b/tests/unit/sampletones_core/formats/famitracker/test_fti.py index b916be82d..0fa16090f 100644 --- a/tests/unit/sampletones_core/formats/famitracker/test_fti.py +++ b/tests/unit/sampletones_core/formats/famitracker/test_fti.py @@ -112,7 +112,8 @@ def envelope(items: Optional[np.ndarray]) -> Envelope[int]: pitch=None if pitch is None else envelope(pitch), hi_pitch=None if hi_pitch is None else envelope(hi_pitch), duty_cycle=None if duty_cycle is None else envelope(duty_cycle), - ) + ), + repitched=False, ) return Instrument2A03(index=index, name=name, sequences=sequences) diff --git a/tests/unit/sampletones_core/formats/famitracker/test_slides.py b/tests/unit/sampletones_core/formats/famitracker/test_slides.py new file mode 100644 index 000000000..023760a47 --- /dev/null +++ b/tests/unit/sampletones_core/formats/famitracker/test_slides.py @@ -0,0 +1,376 @@ +from dataclasses import dataclass +from typing import Final, List, Optional, Tuple + +import pytest + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.general import MAX_PERIOD, MAX_PITCH, MIN_PITCH, NUM_PERIODS +from sampletones_core.exporters.skipped import BuiltDocument, SkippedRow, SkipReason +from sampletones_core.formats.famitracker.builder import build_module +from sampletones_core.formats.famitracker.model.instrument import Instrument2A03 +from sampletones_core.formats.famitracker.model.module import FamiTrackerModule +from sampletones_core.formats.famitracker.model.pattern import RowCell +from sampletones_core.formats.famitracker.module import module_to_ftm_bytes +from sampletones_core.formats.famitracker.slides import slide_effect +from sampletones_core.formats.famitracker.specification.channels import CHANNEL_TO_ID +from sampletones_core.formats.famitracker.specification.patterns import ( + EMPTY_EFFECT, + EMPTY_INSTRUMENT, + EMPTY_NOTE, + FASTEST_SLIDE_SPEED, + MAX_SLIDE_SEMITONES, + SLIDE_SPEED_SHIFT, + EffectId, +) +from sampletones_core.formats.famitracker.specification.sequences import NO_LOOP_POINT, SequenceKind +from sampletones_core.project.patterns.row import Row +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.song import SongTiming +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase +from tests.suite.famitracker import parse_ftm, played_notes +from tests.suite.transposes import ( + contour_sample, + flat_sample, + note, + one_channel_project, + rows_with, + settling_sample, + sounded_pitches, +) + +UNIFORM_SETTINGS: Final[ProjectSettings] = ProjectSettings() +GROOVE_SETTINGS: Final[ProjectSettings] = ProjectSettings(tempo=210) +CHANNEL: Final[ChannelName] = ChannelName.PULSE1 +FRAMES: Final[int] = 240 +FLAT_FRAMES: Final[int] = 8 +FLAT_PITCH: Final[int] = 40 +HIGH_PITCH: Final[int] = 115 +RAISED: Final[int] = 2 +LOWERED: Final[int] = -3 +NOTE_TRANSPOSE: Final[int] = 5 +RESTARTED_TRANSPOSE: Final[int] = 4 +AFTER_RESET_TRANSPOSE: Final[int] = 1 +RAISED_ROW: Final[int] = 3 +LOWERED_ROW: Final[int] = 7 +RESET_ROW: Final[int] = 10 +AFTER_RESET_ROW: Final[int] = 13 +LATE_NOTE_ROW: Final[int] = 12 +FAR_TRANSPOSE: Final[int] = MAX_SLIDE_SEMITONES + 5 +SUBMERGED_TRANSPOSE: Final[int] = -20 +SOARING_TRANSPOSE: Final[int] = 10 +NOISE_PERIOD: Final[int] = 3 +NOISE_TRANSPOSES: Final[Tuple[int, ...]] = (-5, 12, 40) +PITCH_OFFSET: Final[int] = 24 +SHORT_ORDER: Final[Tuple[Optional[int], ...]] = (0, 1, 2) + + +def cell( + document: FamiTrackerModule, + pattern_index: int, + row_number: int, + channel: ChannelName = CHANNEL, +) -> Optional[RowCell]: + """The cell a pattern stores at a row, or ``None`` where the row is empty.""" + channel_id = CHANNEL_TO_ID[channel] + for pattern in document.track.patterns: + if pattern.channel == channel_id and pattern.index == pattern_index: + return next((row for row in pattern.rows if row.row_number == row_number), None) + + return None + + +def slide_at( + document: FamiTrackerModule, + pattern_index: int, + row_number: int, + channel: ChannelName = CHANNEL, +) -> Optional[Tuple[int, int]]: + """The note slide a cell's first effect column writes, or ``None`` where it writes none.""" + row = cell(document, pattern_index, row_number, channel) + if row is None or row.effects[0][0] == EMPTY_EFFECT: + return None + + return row.effects[0] + + +def semitones_up(semitones: int) -> Tuple[int, int]: + return int(EffectId.SLIDE_UP), (FASTEST_SLIDE_SPEED << SLIDE_SPEED_SHIFT) | semitones + + +def semitones_down(semitones: int) -> Tuple[int, int]: + return int(EffectId.SLIDE_DOWN), (FASTEST_SLIDE_SPEED << SLIDE_SPEED_SHIFT) | semitones + + +def instrument_of(document: FamiTrackerModule, index: int) -> Instrument2A03: + return next(instrument for instrument in document.instruments if instrument.index == index) + + +@pytest.fixture(name="lead") +def lead_fixture() -> Sample: + return contour_sample(CHANNEL, FRAMES) + + +def slid(voice: Sample, *cells: Tuple[int, Row]) -> BuiltDocument[FamiTrackerModule]: + return build_module(one_channel_project((voice,), CHANNEL, {0: rows_with(*cells)}, [0], settings=UNIFORM_SETTINGS)) + + +class TestTheSlideATransposeRowWrites: + """``Qxy`` and ``Rxy`` move the channel's note by ``y`` semitones, so a transpose row writes the slide + from the note the channel holds to the note a note-on at the row's transpose would write, at the + highest speed. + """ + + def test_the_encoding_names_the_direction_the_speed_and_the_semitones(self) -> None: + """The effect number and its parameter are the file format's own, which is the contract here.""" + assert slide_effect(RAISED) == (int(EffectId.SLIDE_UP), 0xF0 | RAISED) + assert slide_effect(LOWERED) == (int(EffectId.SLIDE_DOWN), 0xF0 | -LOWERED) + + def test_a_rise_slides_the_note_up(self, lead: Sample) -> None: + document = slid(lead, (0, note(lead)), (RAISED_ROW, Row(transpose=RAISED))).document + + assert slide_at(document, 0, RAISED_ROW) == semitones_up(RAISED) + + def test_the_row_leaves_the_note_and_the_instrument_alone(self, lead: Sample) -> None: + """A note or an instrument cell retriggers the instrument, so the row writes neither.""" + document = slid(lead, (0, note(lead)), (RAISED_ROW, Row(transpose=RAISED))).document + row = cell(document, 0, RAISED_ROW) + + assert row is not None + assert (row.note, row.instrument) == (EMPTY_NOTE, EMPTY_INSTRUMENT) + + def test_a_second_row_slides_from_where_the_first_left_the_note(self, lead: Sample) -> None: + document = slid( + lead, + (0, note(lead)), + (RAISED_ROW, Row(transpose=RAISED)), + (LOWERED_ROW, Row(transpose=LOWERED)), + ).document + + assert slide_at(document, 0, LOWERED_ROW) == semitones_down(RAISED - LOWERED) + + def test_the_slide_counts_from_the_transpose_the_note_started_at(self, lead: Sample) -> None: + document = slid(lead, (0, note(lead, NOTE_TRANSPOSE)), (RAISED_ROW, Row(transpose=RAISED))).document + + assert slide_at(document, 0, RAISED_ROW) == semitones_down(NOTE_TRANSPOSE - RAISED) + + def test_a_note_on_starts_the_slides_over(self, lead: Sample) -> None: + document = slid( + lead, + (0, note(lead)), + (RAISED_ROW, Row(transpose=RAISED)), + (RESET_ROW, note(lead, RESTARTED_TRANSPOSE)), + (AFTER_RESET_ROW, Row(transpose=AFTER_RESET_TRANSPOSE)), + ).document + + assert slide_at(document, 0, RESET_ROW) is None + assert slide_at(document, 0, AFTER_RESET_ROW) == semitones_down(RESTARTED_TRANSPOSE - AFTER_RESET_TRANSPOSE) + + def test_a_row_restating_the_transpose_in_force_writes_no_slide(self, lead: Sample) -> None: + document = slid( + lead, + (0, note(lead)), + (RAISED_ROW, Row(transpose=RAISED)), + (LOWERED_ROW, Row(transpose=RAISED)), + ).document + + assert slide_at(document, 0, LOWERED_ROW) is None + + def test_a_transpose_below_the_range_slides_to_the_lowest_pitch(self) -> None: + """A note-on at the new transpose writes its note by the low-pitch rule, so a flat instrument + slides to the lowest pitch the song plays. + """ + flat = flat_sample(CHANNEL, FLAT_PITCH, FLAT_FRAMES) + document = slid(flat, (0, note(flat)), (RAISED_ROW, Row(transpose=SUBMERGED_TRANSPOSE))).document + + assert slide_at(document, 0, RAISED_ROW) == semitones_down(FLAT_PITCH - MIN_PITCH) + + def test_a_transpose_above_the_range_slides_to_the_highest_pitch(self) -> None: + high = flat_sample(CHANNEL, HIGH_PITCH, FLAT_FRAMES) + document = slid(high, (0, note(high)), (RAISED_ROW, Row(transpose=SOARING_TRANSPOSE))).document + + assert slide_at(document, 0, RAISED_ROW) == semitones_up(MAX_PITCH - HIGH_PITCH) + + def test_a_slide_is_the_same_however_long_the_note_has_sounded(self, lead: Sample) -> None: + """The slide moves the note, which a running arpeggio reads on every tick, so neither the groove + nor the frames between the note and the row change what the row writes. + """ + project = one_channel_project( + (lead,), + CHANNEL, + {0: rows_with((LATE_NOTE_ROW, note(lead))), 1: rows_with((RAISED_ROW, Row(transpose=RAISED)))}, + [0, None, 1], + settings=GROOVE_SETTINGS, + ) + + assert slide_at(build_module(project).document, 1, RAISED_ROW) == semitones_up(RAISED) + + +class TestTheSlidesAModuleCannotCarry: + """A slide reaches fifteen semitones, and a module stores a pattern once for every frame that plays + it, so a row moving the note further or a shared cell needing different slides is written without + one there and reported. The rows after it slide from the note the channel holds. + """ + + def test_a_row_moving_the_note_past_a_slide_is_reported(self, lead: Sample) -> None: + built = slid(lead, (0, note(lead)), (RAISED_ROW, Row(transpose=FAR_TRANSPOSE))) + + assert slide_at(built.document, 0, RAISED_ROW) is None + assert built.skipped_rows == ( + SkippedRow( + voice_id=lead.id, + channel=CHANNEL, + order_position=0, + row_index=RAISED_ROW, + reason=SkipReason.UNREACHED_TRANSPOSE, + ), + ) + + def test_the_row_after_it_slides_from_the_note_the_channel_holds(self, lead: Sample) -> None: + built = slid( + lead, + (0, note(lead)), + (RAISED_ROW, Row(transpose=FAR_TRANSPOSE)), + (LOWERED_ROW, Row(transpose=RAISED)), + ) + + assert slide_at(built.document, 0, LOWERED_ROW) == semitones_up(RAISED) + + def test_a_shared_cell_needing_another_slide_in_a_later_frame_is_reported(self, lead: Sample) -> None: + """The pattern holding the transpose row plays after notes at two transposes, so the slide the + first frame decides moves the second frame's note elsewhere. + """ + project = one_channel_project( + (lead,), + CHANNEL, + { + 0: rows_with((0, note(lead))), + 1: rows_with((RAISED_ROW, Row(transpose=RAISED))), + 2: rows_with((0, note(lead, NOTE_TRANSPOSE))), + }, + [0, 1, 2, 1], + settings=UNIFORM_SETTINGS, + ) + built = build_module(project) + + assert slide_at(built.document, 1, RAISED_ROW) == semitones_up(RAISED) + assert built.skipped_rows == ( + SkippedRow( + voice_id=lead.id, + channel=CHANNEL, + order_position=3, + row_index=RAISED_ROW, + reason=SkipReason.UNREACHED_TRANSPOSE, + ), + ) + + @pytest.mark.parametrize("transpose", NOISE_TRANSPOSES) + def test_every_noise_slide_is_within_reach(self, transpose: int) -> None: + """The note on noise is the period, and both notes lie within the sixteen periods, so a slide + between them reaches every transpose and the channel lands on the period the transpose walks to. + """ + drum = flat_sample(ChannelName.NOISE, NOISE_PERIOD, FLAT_FRAMES) + project = one_channel_project( + (drum,), + ChannelName.NOISE, + {0: rows_with((0, note(drum)), (RAISED_ROW, Row(transpose=transpose)))}, + [0], + settings=UNIFORM_SETTINGS, + ) + built = build_module(project) + effect = slide_at(built.document, 0, RAISED_ROW, ChannelName.NOISE) + + assert built.skipped_rows == () + assert effect is not None + direction = 1 if effect[0] == int(EffectId.SLIDE_UP) else -1 + moved = NOISE_PERIOD + direction * (effect[1] & MAX_SLIDE_SEMITONES) + assert moved == (NOISE_PERIOD + transpose) % NUM_PERIODS + + +class TestTheInstrumentsASlideReaches: + """Only an arpeggio still running reloads the period from the note a slide moved, so an instrument a + slide reaches keeps its arpeggio running, and a module without a slide keeps every instrument as it + was. + """ + + def test_the_arpeggio_of_an_instrument_a_slide_reaches_keeps_running(self, lead: Sample) -> None: + document = slid(lead, (0, note(lead)), (RAISED_ROW, Row(transpose=RAISED))).document + arpeggio = instrument_of(document, 0).sequences[SequenceKind.ARPEGGIO] + + assert arpeggio.loop_point == len(arpeggio.items) - 1 + + def test_an_instrument_no_slide_reaches_is_left_as_it_was(self, lead: Sample) -> None: + document = slid(lead, (0, note(lead)), (RAISED_ROW, Row(transpose=0))).document + arpeggio = instrument_of(document, 0).sequences[SequenceKind.ARPEGGIO] + + assert arpeggio.loop_point == NO_LOOP_POINT + + +def replayed(document: FamiTrackerModule, project: Project, channel_name: ChannelName) -> List[Optional[int]]: + """What FamiTracker sounds on one channel each tick: the pitch, or on noise the period register.""" + notes = played_notes( + parse_ftm(module_to_ftm_bytes(document)), + int(CHANNEL_TO_ID[channel_name]), + SongTiming.from_project(project).groove().ticks, + ) + if channel_name == ChannelName.NOISE: + return [None if value is None else MAX_PERIOD - value for value in notes] + + return [None if value is None else value + PITCH_OFFSET for value in notes] + + +class TestATransposedSongSoundsTheSongsPitch(BaseTestSuite): + """Played the way FamiTracker reads its rows, a song whose transpose rows move a sounding note, cross + frames under a groove, and meet a note-on starting over sounds the pitch the song's walk sounds on + every tick the song sounds. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + channel: ChannelName + settling: bool + + test_cases: Tuple["TestATransposedSongSoundsTheSongsPitch.TestCase", ...] = ( + TestCase(label="pulse", channel=ChannelName.PULSE1, settling=False), + TestCase(label="triangle", channel=ChannelName.TRIANGLE, settling=False), + TestCase(label="noise", channel=ChannelName.NOISE, settling=False), + TestCase(label="pulse_settling", channel=ChannelName.PULSE1, settling=True), + TestCase(label="triangle_settling", channel=ChannelName.TRIANGLE, settling=True), + TestCase(label="noise_settling", channel=ChannelName.NOISE, settling=True), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_every_sounding_tick_plays_the_songs_pitch( + self, + test_case: "TestATransposedSongSoundsTheSongsPitch.TestCase", + ) -> None: + """A settling voice's arpeggio has played its items before the first transpose row comes.""" + voice = ( + settling_sample(test_case.channel, FRAMES) + if test_case.settling + else contour_sample(test_case.channel, FRAMES) + ) + patterns = { + 0: rows_with((0, note(voice)), (RAISED_ROW, Row(transpose=RAISED))), + 1: rows_with( + (2, Row(transpose=LOWERED)), + (RESET_ROW, note(voice)), + (AFTER_RESET_ROW, Row(transpose=AFTER_RESET_TRANSPOSE)), + ), + 2: rows_with((RAISED_ROW, Row(transpose=LOWERED)), (LOWERED_ROW, Row(transpose=0))), + } + project = one_channel_project( + (voice,), + test_case.channel, + patterns, + list(SHORT_ORDER), + settings=GROOVE_SETTINGS, + ) + + sounded = sounded_pitches(project, test_case.channel) + played = replayed(build_module(project).document, project, test_case.channel) + + assert len(played) == len(sounded) + assert [tick for tick, pitch in enumerate(sounded) if pitch is not None and played[tick] != pitch] == [] diff --git a/tests/unit/sampletones_core/formats/famitracker/test_voice.py b/tests/unit/sampletones_core/formats/famitracker/test_voice.py index c3c2d2383..a6751c31a 100644 --- a/tests/unit/sampletones_core/formats/famitracker/test_voice.py +++ b/tests/unit/sampletones_core/formats/famitracker/test_voice.py @@ -198,6 +198,7 @@ def round_trip(voice: Instrument) -> ImportedVoice: STANDALONE_INSTRUMENT_INDEX, voice.name, voice.instrument_features(), + repitched=False, ) return instrument_to_voice(fti_bytes_to_instrument(instrument_to_fti_bytes(tracker))) diff --git a/tests/unit/sampletones_core/performance/test_bend.py b/tests/unit/sampletones_core/performance/test_bend.py index 467eec68e..62a7d8c31 100644 --- a/tests/unit/sampletones_core/performance/test_bend.py +++ b/tests/unit/sampletones_core/performance/test_bend.py @@ -10,10 +10,13 @@ ChannelPerformance, VoiceReading, apply_modifiers, + apply_row, sound_tick, ) +from sampletones_core.project.patterns.row import Row from sampletones_core.project.voices.envelopes import InstrumentEnvelopes from sampletones_core.project.voices.instrument import Instrument +from sampletones_core.project.voices.note_on import NoteOn REFERENCE: Final[int] = 60 SEMITONE: Final[int] = 1 @@ -62,17 +65,25 @@ def test_both_dimensions_reach_the_frame_together(self) -> None: assert played[0].detune == BEND assert played[0].coarse_detune == COARSE_BEND - def test_a_channel_holds_the_bend_a_voice_left_it(self) -> None: - """A voice writing no bend sounds at what the channel holds, which is what the last one set.""" - reading = VoiceReading.read(_instrument(), ChannelName.PULSE1) - assert reading is not None - - performance = ChannelPerformance(voice_id="held") - performance.feature_values[FeatureKey.PITCH] = BEND - sounded = sound_tick(performance, reading) - - assert sounded is not None - assert sounded.detune == BEND + def test_a_voice_writing_no_bend_starts_unbent_after_one_that_bent(self) -> None: + """A bend ends with the note that wrote it, so the next note sounds on its own divider.""" + bent = _instrument( + pitch=Envelope(items=(BEND,)), + hi_pitch=Envelope(items=(COARSE_BEND,)), + ) + plain = _instrument() + bent_reading = VoiceReading.read(bent, ChannelName.PULSE1) + plain_reading = VoiceReading.read(plain, ChannelName.PULSE1) + assert bent_reading is not None and plain_reading is not None + + performance = ChannelPerformance() + apply_row(performance, Row(command=NoteOn(voice_id=bent.id))) + sound_tick(performance, bent_reading) + apply_row(performance, Row(command=NoteOn(voice_id=plain.id))) + sounded = sound_tick(performance, plain_reading) + + assert isinstance(sounded, PulseInstruction) + assert (sounded.detune, sounded.coarse_detune) == (0, 0) def test_a_bend_a_voice_writes_becomes_what_the_channel_holds(self) -> None: reading = VoiceReading.read(_instrument(pitch=Envelope(items=(BEND,))), ChannelName.PULSE1) diff --git a/tests/unit/sampletones_core/performance/test_modifiers.py b/tests/unit/sampletones_core/performance/test_modifiers.py index 80f2b5cac..6d9abacf1 100644 --- a/tests/unit/sampletones_core/performance/test_modifiers.py +++ b/tests/unit/sampletones_core/performance/test_modifiers.py @@ -1,17 +1,36 @@ from dataclasses import dataclass -from typing import Any +from itertools import product +from typing import Any, Final, Tuple import pytest -from sampletones_core.constants.general import MAX_PITCH, MAX_VOLUME, MIN_PITCH +from sampletones_core.constants.general import ( + MAX_PITCH, + MAX_VOLUME, + MIN_PITCH, + MIN_VOLUME, + SILENT_VOLUME, + VOLUME_RANGE, +) from sampletones_core.instructions import ( NoiseInstruction, PulseInstruction, TriangleInstruction, ) from sampletones_core.performance import apply_modifiers +from sampletones_core.performance.modifiers import triangle_sounds_at from tests.suite.case import BaseRegularTestCase, BaseTestCase +LEVEL_PAIRS: Final[Tuple[Tuple[int, int], ...]] = tuple(product(VOLUME_RANGE, VOLUME_RANGE)) + + +def _pulse(volume: int) -> PulseInstruction: + return PulseInstruction(on=True, pitch=60, volume=volume, duty_cycle=0) + + +def _noise(volume: int) -> NoiseInstruction: + return NoiseInstruction(on=True, period=3, volume=volume, short=False) + @dataclass(frozen=True, kw_only=True) class VolumeScalingCase(BaseRegularTestCase): @@ -78,41 +97,68 @@ def test_volume_scaled_correctly( self, case: VolumeScalingCase, ) -> None: - instruction = PulseInstruction( - on=True, - pitch=60, - volume=case.instruction_volume, - duty_cycle=0, - ) result = apply_modifiers( - instruction, + _pulse(case.instruction_volume), transpose=0, row_volume=case.row_volume, ) assert isinstance(result, PulseInstruction) assert result.volume == case.expected + def test_every_pair_of_levels_rounds_to_the_nearest_step(self) -> None: + sounded = { + (volume, row_volume): apply_modifiers(_pulse(volume), transpose=0, row_volume=row_volume).volume + for volume, row_volume in LEVEL_PAIRS + } + + assert sounded == { + (volume, row_volume): round(volume * row_volume / MAX_VOLUME) for volume, row_volume in LEVEL_PAIRS + } + + +NOISE_VOLUME_CASES = [ + VolumeScalingCase(label="max×max", instruction_volume=15, row_volume=15, expected=15), + VolumeScalingCase(label="a fraction rounds down", instruction_volume=13, row_volume=8, expected=6), + VolumeScalingCase(label="ten×ten rounds down", instruction_volume=10, row_volume=10, expected=6), + VolumeScalingCase(label="one×one keeps the quietest level", instruction_volume=1, row_volume=1, expected=1), + VolumeScalingCase(label="a quiet row keeps a loud note audible", instruction_volume=15, row_volume=1, expected=1), + VolumeScalingCase(label="a quiet note under a loud row", instruction_volume=1, row_volume=14, expected=1), + VolumeScalingCase(label="the loudest product below one step", instruction_volume=7, row_volume=2, expected=1), + VolumeScalingCase(label="a silent row silences", instruction_volume=15, row_volume=0, expected=0), + VolumeScalingCase(label="a silent instruction stays silent", instruction_volume=0, row_volume=15, expected=0), +] + class TestNoiseVolumeScaling: - @pytest.mark.parametrize("case", VOLUME_SCALING_CASES, ids=lambda c: c.label) + """The noise level follows the rule FamiTracker and Bitphase share: the product rounded down, and the + quietest level wherever that comes out silent while both levels sound.""" + + @pytest.mark.parametrize("case", NOISE_VOLUME_CASES, ids=lambda c: c.label) def test_volume_scaled_correctly( self, case: VolumeScalingCase, ) -> None: - instruction = NoiseInstruction( - on=True, - period=3, - volume=case.instruction_volume, - short=False, - ) result = apply_modifiers( - instruction, + _noise(case.instruction_volume), transpose=0, row_volume=case.row_volume, ) assert isinstance(result, NoiseInstruction) assert result.volume == case.expected + def test_every_pair_of_levels_follows_the_trackers_rule(self) -> None: + sounded = { + (volume, row_volume): apply_modifiers(_noise(volume), transpose=0, row_volume=row_volume).volume + for volume, row_volume in LEVEL_PAIRS + } + + assert sounded == { + (volume, row_volume): ( + max(MIN_VOLUME, volume * row_volume // MAX_VOLUME) if volume * row_volume else SILENT_VOLUME + ) + for volume, row_volume in LEVEL_PAIRS + } + @dataclass(frozen=True, kw_only=True) class PulseTransposeCase(BaseTestCase): @@ -365,3 +411,14 @@ def test_modifiers_applied( assert isinstance(result, TriangleInstruction) assert result.pitch == case.expected_pitch assert result.on == case.expected_on + + +class TestTheTriangleGate: + def test_the_triangle_sounds_at_every_level_the_gate_opens_at(self) -> None: + """A tracker export writes the triangle's volume column by the gate, so the gate is the rule + the song sounds the triangle by at every level a row can state. + """ + instruction = TriangleInstruction(on=True, pitch=MIN_PITCH) + sounded = [apply_modifiers(instruction, transpose=0, row_volume=volume).on for volume in VOLUME_RANGE] + + assert sounded == [triangle_sounds_at(volume) for volume in VOLUME_RANGE] diff --git a/tests/unit/sampletones_core/performance/test_rows.py b/tests/unit/sampletones_core/performance/test_rows.py index 839ad9e24..295f187ac 100644 --- a/tests/unit/sampletones_core/performance/test_rows.py +++ b/tests/unit/sampletones_core/performance/test_rows.py @@ -1,23 +1,40 @@ from dataclasses import dataclass -from typing import Optional, Tuple +from typing import Dict, Final, Optional, Tuple import pytest -from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.constants.general import MAX_VOLUME -from sampletones_core.performance import ChannelPerformance, apply_row, resolve_row +from sampletones_core.features import CHANNEL_FEATURE_DEFAULTS +from sampletones_core.performance import ( + ChannelPerformance, + VoiceReading, + apply_row, + resolve_row, + sound_tick, +) from sampletones_core.project.patterns.row import Row from sampletones_core.project.song import Song from sampletones_core.project.song_position import SongPosition from sampletones_core.project.voices.note_off import NoteOff from sampletones_core.project.voices.note_on import NoteOn +from sampletones_core.project.voices.sample import Sample from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase +from tests.suite.performance import make_pulse_reconstruction ROWS_PER_PATTERN: int = 4 SOUNDING_ROW: int = 2 SAMPLE_ID: str = "sample" ANOTHER_SAMPLE_ID: str = "another" +QUIET_VOLUME: Final[int] = 3 +LEFT_BEHIND: Final[Dict[FeatureKey, int]] = { + FeatureKey.VOLUME: 0, + FeatureKey.ARPEGGIO: 7, + FeatureKey.PITCH: -3, + FeatureKey.HI_PITCH: 1, + FeatureKey.DUTY_CYCLE: 2, +} def _song() -> Song: @@ -175,3 +192,88 @@ def test_the_tick_index_returns_to_the_start_exactly_where_the_note_does( retriggered = apply_row(performance, test_case.row) assert (performance.tick_index == 0) is retriggered + + +class TestANoteStartsItsDimensionsOver(BaseTestSuite): + """A note starts every envelope dimension from where a song starts, whatever the one before it left. + + A row naming no note plays on inside the note already sounding, so it leaves the dimensions + where that note has taken them. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + row: Row + expected: Dict[FeatureKey, int] + + test_cases: Tuple["TestANoteStartsItsDimensionsOver.TestCase", ...] = ( + TestCase( + label="a note column", + row=Row(command=NoteOn(voice_id=SAMPLE_ID)), + expected=CHANNEL_FEATURE_DEFAULTS, + ), + TestCase( + label="a note column with modifiers", + row=Row( + command=NoteOn(voice_id=SAMPLE_ID), + transpose=5, + volume=8, + ), + expected=CHANNEL_FEATURE_DEFAULTS, + ), + TestCase( + label="an empty row", + row=Row(), + expected=LEFT_BEHIND, + ), + TestCase( + label="a modifier row", + row=Row(transpose=-2, volume=4), + expected=LEFT_BEHIND, + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_dimensions_the_channel_carries_after_the_row(self, test_case: TestCase) -> None: + performance = ChannelPerformance( + voice_id=ANOTHER_SAMPLE_ID, + tick_index=6, + feature_values=LEFT_BEHIND.copy(), + ) + + apply_row(performance, test_case.row) + + assert performance.feature_values == test_case.expected + + def test_a_note_leaves_the_defaults_themselves_untouched(self) -> None: + """Each note takes a copy, so what a note writes reaches neither the next nor a song's start.""" + defaults = dict(CHANNEL_FEATURE_DEFAULTS) + performance = ChannelPerformance(voice_id=ANOTHER_SAMPLE_ID) + apply_row(performance, Row(command=NoteOn(voice_id=SAMPLE_ID))) + + performance.feature_values[FeatureKey.ARPEGGIO] += 1 + + assert CHANNEL_FEATURE_DEFAULTS == defaults + + def test_a_sample_holding_its_level_after_a_quieter_one_sounds_at_full_volume(self) -> None: + """A level one sample wrote ends with its note, so the next leaves its level where a song starts.""" + writes = Sample(name="writes", reconstruction=make_pulse_reconstruction(volume=QUIET_VOLUME)) + holds = Sample( + name="holds", + reconstruction=make_pulse_reconstruction( + volume=MAX_VOLUME, + held_features=(FeatureKey.VOLUME,), + ), + ) + writes_reading = VoiceReading.read(writes, ChannelName.PULSE1) + holds_reading = VoiceReading.read(holds, ChannelName.PULSE1) + assert writes_reading is not None and holds_reading is not None + + performance = ChannelPerformance() + apply_row(performance, Row(command=NoteOn(voice_id=writes.id))) + sound_tick(performance, writes_reading) + apply_row(performance, Row(command=NoteOn(voice_id=holds.id))) + sounded = sound_tick(performance, holds_reading) + + assert sounded is not None and sounded.on is True + assert performance.feature_values[FeatureKey.VOLUME] == MAX_VOLUME diff --git a/tests/unit/sampletones_core/performance/test_song.py b/tests/unit/sampletones_core/performance/test_song.py index 2be4f2a4b..37b1a329b 100644 --- a/tests/unit/sampletones_core/performance/test_song.py +++ b/tests/unit/sampletones_core/performance/test_song.py @@ -1,23 +1,51 @@ -from typing import Final, List +from dataclasses import dataclass +from typing import Final, List, Optional import pytest from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.general import MAX_VOLUME from sampletones_core.exporters.maps import CHANNEL_TO_EXPORTER_MAP +from sampletones_core.features.envelope import Envelope +from sampletones_core.instructions import ( + InstructionUnion, + NoiseInstruction, + PulseInstruction, + TriangleInstruction, +) from sampletones_core.performance import WalkProgress, song_instructions from sampletones_core.project.project import Project 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_shared.exceptions import OperationCanceled +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase from tests.suite.performance import ( make_pulse_reconstruction, place_instrument, + project_with_instrument, project_with_sample, ) ROWS_PER_PATTERN: Final[int] = 4 ENVELOPE_TICKS: Final[int] = 2 SETTINGS: Final[ProjectSettings] = ProjectSettings(tempo=150, speed=6, nes_frequency=60) +FOLLOWER_PITCH: Final[int] = 57 +FOLLOWER_PERIOD: Final[int] = 4 +ROW_VOLUME: Final[int] = 10 +LEADER_ENVELOPES: Final[InstrumentEnvelopes] = InstrumentEnvelopes( + volume=Envelope(items=(6,)), + arpeggio=Envelope(items=(7,)), + pitch=Envelope(items=(-3,)), + hi_pitch=Envelope(items=(1,)), + duty_cycle=Envelope(items=(1,)), +) +RELEASED_ENVELOPES: Final[InstrumentEnvelopes] = InstrumentEnvelopes( + volume=Envelope(items=(MAX_VOLUME, 0)), + duty_cycle=Envelope(items=(1,)), +) def _project() -> Project: @@ -117,3 +145,131 @@ def test_the_walk_counts_up_as_it_goes(self) -> None: def test_a_withdrawn_walk_stops_where_it_was_told(self) -> None: with pytest.raises(OperationCanceled): song_instructions(_project(), lambda progress: False) + + +def _following( + leader: InstrumentEnvelopes, + follower: InstrumentEnvelopes, + channel_name: ChannelName, + *, + volume: Optional[int] = None, +) -> InstructionUnion: + """The first frame a voice sounds on the row after a whole row of another voice.""" + first = Instrument(name="leader", envelopes=leader) + second = Instrument( + name="follower", + envelopes=follower, + initial_pitch=FOLLOWER_PITCH, + initial_period=FOLLOWER_PERIOD, + ) + project = project_with_instrument(first, rows_per_pattern=ROWS_PER_PATTERN, settings=SETTINGS) + project.voices.append(second) + place_instrument(project, channel_name=channel_name, row_index=0, sample=first) + 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]] + + +class TestANoteStartsFromWhereASongStarts(BaseTestSuite): + """A voice leaving a dimension empty sounds it where a song starts it, whatever came before. + + The note before writes every dimension away from that start. The note after writes one + dimension, leaves the rest empty, and sounds full volume, no arpeggio offset, no bend, and the + first duty cycle or the long noise mode, which is how FamiTracker and Bitphase start a note. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + channel_name: ChannelName + follower: InstrumentEnvelopes + expected: InstructionUnion + + test_cases = ( + TestCase( + label="pulse leaving its arpeggio, bend and duty cycle", + channel_name=ChannelName.PULSE1, + follower=InstrumentEnvelopes(volume=Envelope(items=(MAX_VOLUME,))), + expected=PulseInstruction( + on=True, + pitch=FOLLOWER_PITCH, + volume=MAX_VOLUME, + duty_cycle=0, + detune=0, + coarse_detune=0, + ), + ), + TestCase( + label="pulse leaving its volume", + channel_name=ChannelName.PULSE1, + follower=InstrumentEnvelopes(arpeggio=Envelope(items=(0,))), + expected=PulseInstruction( + on=True, + pitch=FOLLOWER_PITCH, + volume=MAX_VOLUME, + duty_cycle=0, + detune=0, + coarse_detune=0, + ), + ), + TestCase( + label="triangle leaving its arpeggio and bend", + channel_name=ChannelName.TRIANGLE, + follower=InstrumentEnvelopes(volume=Envelope(items=(MAX_VOLUME,))), + expected=TriangleInstruction( + on=True, + pitch=FOLLOWER_PITCH, + detune=0, + coarse_detune=0, + ), + ), + TestCase( + label="noise leaving its arpeggio and mode", + channel_name=ChannelName.NOISE, + follower=InstrumentEnvelopes(volume=Envelope(items=(MAX_VOLUME,))), + expected=NoiseInstruction( + on=True, + period=FOLLOWER_PERIOD, + volume=MAX_VOLUME, + short=False, + ), + ), + TestCase( + label="noise leaving its volume", + channel_name=ChannelName.NOISE, + follower=InstrumentEnvelopes(arpeggio=Envelope(items=(0,))), + expected=NoiseInstruction( + on=True, + period=FOLLOWER_PERIOD, + volume=MAX_VOLUME, + short=False, + ), + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_note_after_sounds_the_empty_dimensions_at_their_start(self, test_case: TestCase) -> None: + sounded = _following(LEADER_ENVELOPES, test_case.follower, test_case.channel_name) + + assert sounded == test_case.expected + + @pytest.mark.parametrize( + "channel_name", + (ChannelName.PULSE1, ChannelName.NOISE), + ids=lambda channel_name: channel_name.value, + ) + def test_a_voice_leaving_its_volume_sounds_at_the_rows_after_one_that_released( + self, + channel_name: ChannelName, + ) -> None: + """A note that ended at silence leaves the next one its row's level, the way a tracker plays it.""" + sounded = _following( + RELEASED_ENVELOPES, + InstrumentEnvelopes(arpeggio=Envelope(items=(0, 12), loop_point=0)), + channel_name, + volume=ROW_VOLUME, + ) + + assert isinstance(sounded, (PulseInstruction, NoiseInstruction)) + assert sounded.on is True + assert sounded.volume == ROW_VOLUME diff --git a/tests/unit/sampletones_core/performance/test_voice.py b/tests/unit/sampletones_core/performance/test_voice.py index 12d461c49..c8e2d833d 100644 --- a/tests/unit/sampletones_core/performance/test_voice.py +++ b/tests/unit/sampletones_core/performance/test_voice.py @@ -280,16 +280,6 @@ def test_a_held_dimension_leaves_the_channels_value_where_it_stands(self, test_c assert values[test_case.held_feature] == test_case.channel_value - def test_a_level_one_instrument_wrote_is_what_the_next_one_holds(self) -> None: - """The channel carries a value across samples, which is what makes an empty envelope mean this.""" - writes = _voice(ChannelName.PULSE1, [self._INSTRUCTION], ()) - holds = _voice(ChannelName.PULSE1, [self._INSTRUCTION], (FeatureKey.VOLUME,)) - values = _channel_values() - - writes.sound(self._INSTRUCTION, values) - - assert holds.sound(self._INSTRUCTION, values).volume == SAMPLE_VOLUME - def test_an_instrument_holding_its_level_sounds_a_silent_frame(self) -> None: """Silence is stated by a volume envelope, so an instrument leaving one out plays on.""" rest = PulseInstruction.null_instruction() diff --git a/tests/unit/sampletones_core/reconstructions/reconstruction/test_stems_removal.py b/tests/unit/sampletones_core/reconstructions/reconstruction/test_stems_removal.py index 2e51062da..b20933d54 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstruction/test_stems_removal.py +++ b/tests/unit/sampletones_core/reconstructions/reconstruction/test_stems_removal.py @@ -12,7 +12,7 @@ from sampletones_core.reconstructions.reconstruction.reconstruction import Reconstruction from sampletones_core.reconstructions.reconstruction.stems.channel_assignment import ChannelAssignment from sampletones_core.reconstructions.reconstruction.stems.data import StemsData -from sampletones_core.reconstructions.reconstruction.stems.removal import without_stem +from sampletones_core.reconstructions.reconstruction.stems.removal import can_remove_stem, without_stem from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig from sampletones_core.reconstructions.reconstructor.stems.configs.entry import StemEntry from sampletones_core.reconstructions.reconstructor.stems.configs.hierarchy import StemsHierarchy @@ -276,28 +276,48 @@ def test_the_source_reconstruction_is_left_as_it_stood(self, reconstruction: Rec np.testing.assert_array_equal(reconstruction.approximations[ChannelName.NOISE], before) +@pytest.fixture +def single_recording() -> Reconstruction: + """A reconstruction built from one recording alone.""" + return Reconstruction.create( + instructions={ChannelName.PULSE1: [_pulse(60)] * FRAME_COUNT}, + config=Config(), + coefficient=1.0, + audio_filepath=(RECORDINGS[STEM_A],), + stems_data=StemsData.single_entry( + StemSettings(channels=[ChannelName.PULSE1], bends=[ChannelName.PULSE1]), + [ + ChannelAssignment( + channel_name=ChannelName.PULSE1, + stem_ids=[STEM_A] * FRAME_COUNT, + ) + ], + RECORDED_SCALE, + ), + ) + + class TestARefusedRemoval: def test_removing_an_unrecorded_stem_is_refused(self, reconstruction: Reconstruction) -> None: with pytest.raises(ValueError, match="names no entry"): without_stem(reconstruction, 7) - def test_removing_the_last_recording_is_refused(self) -> None: - reconstruction = Reconstruction.create( - instructions={ChannelName.PULSE1: [_pulse(60)] * FRAME_COUNT}, - config=Config(), - coefficient=1.0, - audio_filepath=(RECORDINGS[STEM_A],), - stems_data=StemsData.single_entry( - StemSettings(channels=[ChannelName.PULSE1], bends=[ChannelName.PULSE1]), - [ - ChannelAssignment( - channel_name=ChannelName.PULSE1, - stem_ids=[STEM_A] * FRAME_COUNT, - ) - ], - RECORDED_SCALE, - ), - ) - + def test_removing_the_last_recording_is_refused(self, single_recording: Reconstruction) -> None: with pytest.raises(ValueError, match="at least one stem"): - without_stem(reconstruction, STEM_A) + without_stem(single_recording, STEM_A) + + +class TestWhetherARemovalApplies: + """A removal asked about first answers as :func:`without_stem` would, without raising.""" + + def test_a_recording_standing_beside_others_can_leave(self, reconstruction: Reconstruction) -> None: + assert can_remove_stem(reconstruction, STEM_B) + + def test_an_unrecorded_stem_cannot(self, reconstruction: Reconstruction) -> None: + assert not can_remove_stem(reconstruction, 7) + + def test_the_last_recording_cannot(self, single_recording: Reconstruction) -> None: + assert not can_remove_stem(single_recording, STEM_A) + + def test_a_recording_already_taken_out_cannot(self, reconstruction: Reconstruction) -> None: + assert not can_remove_stem(without_stem(reconstruction, STEM_B), STEM_B) diff --git a/tests/unit/sampletones_core/timing/test_groove.py b/tests/unit/sampletones_core/timing/test_groove.py index 5dc4840cf..82933e00a 100644 --- a/tests/unit/sampletones_core/timing/test_groove.py +++ b/tests/unit/sampletones_core/timing/test_groove.py @@ -881,3 +881,23 @@ def test_a_constant_groove_is_uniform(self) -> None: 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 diff --git a/tests/unit/sampletones_shared/utils/system/test_paths.py b/tests/unit/sampletones_shared/utils/system/test_paths.py index ab24f389b..f5b82ad8d 100644 --- a/tests/unit/sampletones_shared/utils/system/test_paths.py +++ b/tests/unit/sampletones_shared/utils/system/test_paths.py @@ -12,6 +12,7 @@ first_missing, get_directory, get_filename, + is_same_path, nearest_directory, open_directory_in_explorer_linux, open_file_in_explorer_linux, @@ -46,6 +47,30 @@ def test_answers_none_when_every_path_stands(self, tmp_path: Path) -> None: assert first_missing((first, second)) is None +class TestIsSamePath: + """Two spellings of one location name the same file, which is what tells a document its own.""" + + def test_a_path_is_the_same_as_itself(self, tmp_path: Path) -> None: + assert is_same_path(tmp_path / "song.stn", tmp_path / "song.stn") + + def test_a_detour_through_a_parent_reaches_the_same_file(self, tmp_path: Path) -> None: + (tmp_path / "folder").mkdir() + + assert is_same_path(tmp_path / "folder" / ".." / "song.stn", tmp_path / "song.stn") + + def test_a_relative_spelling_reaches_the_same_file( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.chdir(tmp_path) + + assert is_same_path(Path("song.stn"), tmp_path / "song.stn") + + def test_two_files_are_different(self, tmp_path: Path) -> None: + assert not is_same_path(tmp_path / "song.stn", tmp_path / "other.stn") + + class TestNearestDirectory: def test_a_standing_directory_answers_itself(self, tmp_path: Path) -> None: assert nearest_directory(tmp_path) == tmp_path diff --git a/tests/unit/sampletones_tools/corpus/test_build.py b/tests/unit/sampletones_tools/corpus/test_build.py index 8fcbd8d65..9d295626b 100644 --- a/tests/unit/sampletones_tools/corpus/test_build.py +++ b/tests/unit/sampletones_tools/corpus/test_build.py @@ -23,7 +23,7 @@ def test_the_project_carries_the_module_the_voices_and_the_song(self) -> None: spec = SongSpec( rows_per_pattern=2, order=[{ChannelName.TRIANGLE: 0}], - channels={ChannelName.TRIANGLE: ChannelSpec(patterns={0: [RowSpec(row=0, sample="bass")]})}, + channels={ChannelName.TRIANGLE: ChannelSpec(patterns={0: [RowSpec(row=0, voice="bass")]})}, ) project = build_project(catalog, MODULE, spec) diff --git a/tests/unit/sampletones_tools/corpus/test_song.py b/tests/unit/sampletones_tools/corpus/test_song.py index 6644d0b91..f32aa02d9 100644 --- a/tests/unit/sampletones_tools/corpus/test_song.py +++ b/tests/unit/sampletones_tools/corpus/test_song.py @@ -42,7 +42,7 @@ def test_every_written_row_reaches_its_pattern(self) -> None: spec = _spec( ChannelName.PULSE1, { - 0: RowSpec(row=0, sample="lead", transpose=12, volume=10), + 0: RowSpec(row=0, voice="lead", transpose=12, volume=10), 2: RowSpec(row=2, off=True), 3: RowSpec(row=3, volume=4), }, @@ -59,16 +59,16 @@ def test_every_written_row_reaches_its_pattern(self) -> None: assert (rows[3].command, rows[3].volume) == (None, 4) def test_the_order_names_every_channel_and_every_channel_is_present(self) -> None: - song = build_song(_spec(ChannelName.NOISE, {0: RowSpec(row=0, sample="hihat")}), _catalog()) + song = build_song(_spec(ChannelName.NOISE, {0: RowSpec(row=0, voice="hihat")}), _catalog()) assert song.order == [{channel: 0 if channel == ChannelName.NOISE else None for channel in ChannelName.items()}] assert set(song.channels) == set(ChannelName.items()) assert song.channels[ChannelName.PULSE1].patterns == {} - def test_a_sample_the_catalog_lacks_is_refused(self) -> None: + def test_a_voice_the_catalog_lacks_is_refused(self) -> None: with pytest.raises(KeyError, match="snare"): - build_song(_spec(ChannelName.NOISE, {0: RowSpec(row=0, sample="snare")}), _catalog()) + build_song(_spec(ChannelName.NOISE, {0: RowSpec(row=0, voice="snare")}), _catalog()) - def test_a_sample_on_a_channel_it_has_no_slice_for_is_refused(self) -> None: - with pytest.raises(ValueError, match="Sample 'lead' has no 'noise' slice"): - build_song(_spec(ChannelName.NOISE, {0: RowSpec(row=0, sample="lead")}), _catalog()) + def test_a_voice_on_a_channel_it_has_no_slice_for_is_refused(self) -> None: + with pytest.raises(ValueError, match="Voice 'lead' has no 'noise' slice"): + build_song(_spec(ChannelName.NOISE, {0: RowSpec(row=0, voice="lead")}), _catalog()) diff --git a/tests/unit/sampletones_tools/corpus/test_written.py b/tests/unit/sampletones_tools/corpus/test_written.py new file mode 100644 index 000000000..df572dc03 --- /dev/null +++ b/tests/unit/sampletones_tools/corpus/test_written.py @@ -0,0 +1,45 @@ +from typing import Dict, Final, List + +from sampletones_core.constants.algorithm import RESTING_STEM_ID +from sampletones_core.constants.enums import ChannelName +from sampletones_core.instructions import InstructionUnion, PulseInstruction +from sampletones_tools.corpus.written import ( + SINGLE_STEM_ID, + single_recording_record, + written_reconstruction, +) + +SCALE: Final[float] = 0.5 +COEFFICIENT: Final[float] = 0.75 +FRAMES: Final[Dict[ChannelName, List[InstructionUnion]]] = { + ChannelName.PULSE1: [ + PulseInstruction(on=True, pitch=60, volume=15, duty_cycle=2), + PulseInstruction(on=False, pitch=60, volume=0, duty_cycle=2), + PulseInstruction(on=True, pitch=62, volume=9, duty_cycle=1), + ] +} + + +class TestSingleRecordingRecord: + def test_a_sounding_frame_answers_to_the_recording_and_a_silent_one_to_rest(self) -> None: + record = single_recording_record(FRAMES, SCALE) + + assert [(assignment.channel_name, assignment.stem_ids) for assignment in record.assignments] == [ + (ChannelName.PULSE1, [SINGLE_STEM_ID, RESTING_STEM_ID, SINGLE_STEM_ID]) + ] + assert record.scale == SCALE + + +class TestWrittenReconstruction: + def test_the_reconstruction_plays_the_frames_it_is_given_and_names_no_recording(self) -> None: + reconstruction = written_reconstruction( + FRAMES, + coefficient=COEFFICIENT, + scale=SCALE, + audio_filepath=(), + ) + + assert list(reconstruction.instructions[ChannelName.PULSE1]) == FRAMES[ChannelName.PULSE1] + assert reconstruction.playing_channels == (ChannelName.PULSE1,) + assert reconstruction.coefficient == COEFFICIENT + assert not reconstruction.stems_data.sources diff --git a/tests/unit/sampletones_tools/player/trace/test_trace.py b/tests/unit/sampletones_tools/player/trace/test_trace.py index f585cc9a4..2cebe5570 100644 --- a/tests/unit/sampletones_tools/player/trace/test_trace.py +++ b/tests/unit/sampletones_tools/player/trace/test_trace.py @@ -3,6 +3,8 @@ import pytest +from sampletones_core.constants.enums import ChannelName +from sampletones_player.specification.channels import CHANNEL_REGISTER_ADDRESSES from sampletones_player.specification.registers import ( APU_FRAME_COUNTER, APU_STATUS, @@ -22,7 +24,7 @@ SWEEP_DISABLED, TRIANGLE_TIMER_HIGH, ) -from sampletones_tools.player.trace.trace import RegisterTrace +from sampletones_tools.player.trace.trace import RegisterTrace, channel_writes, setup_writes from sampletones_tools.player.trace.write import RegisterWrite from tests.suite.base import BaseTestSuite from tests.suite.case import BaseAutolabelTestCase @@ -101,6 +103,29 @@ def test_the_first_tick_writes_the_registers_that_reset_a_channel(self) -> None: assert REGISTERS_WRITTEN_ON_CHANGE.issubset(set(addresses(first_tick))) +class TestTickWrites: + """The writes the init routine opens with and the writes one tick's channels make.""" + + def test_the_setup_opens_the_initialization(self) -> None: + song = player_song(resting_streams((SOUNDING,)), NTSC_FREQUENCY, loop_tick=None) + + initialization = RegisterTrace.from_song(song, play_calls=0).initialization + + assert initialization[: len(setup_writes())] == setup_writes() + + def test_a_tick_writes_every_register_each_channel_owns_in_channel_order(self) -> None: + streams = resting_streams((SOUNDING,)) + + writes = channel_writes(streams.at(0)) + + assert addresses(writes) == tuple( + address for channel in ChannelName.items() for address in CHANNEL_REGISTER_ADDRESSES[channel] + ) + assert tuple(write.value for write in writes) == tuple( + value for registers in streams.at(0) for value in registers.values + ) + + class TestChangeSuppression: """The three registers that disturb a running channel are written only where they change.""" diff --git a/tests/unit/sampletones_tools/tracker_playback/__init__.py b/tests/unit/sampletones_tools/tracker_playback/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_tools/tracker_playback/commands/__init__.py b/tests/unit/sampletones_tools/tracker_playback/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_tools/tracker_playback/conftest.py b/tests/unit/sampletones_tools/tracker_playback/conftest.py new file mode 100644 index 000000000..5b08bbbf9 --- /dev/null +++ b/tests/unit/sampletones_tools/tracker_playback/conftest.py @@ -0,0 +1,8 @@ +import pytest + +from tests.suite.playback import ReplayingTarget + + +@pytest.fixture(name="replaying_target") +def replaying_target_fixture() -> ReplayingTarget: + return ReplayingTarget() diff --git a/tests/unit/sampletones_tools/tracker_playback/corpus/__init__.py b/tests/unit/sampletones_tools/tracker_playback/corpus/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_tools/tracker_playback/corpus/test_build.py b/tests/unit/sampletones_tools/tracker_playback/corpus/test_build.py new file mode 100644 index 000000000..f3da2f169 --- /dev/null +++ b/tests/unit/sampletones_tools/tracker_playback/corpus/test_build.py @@ -0,0 +1,104 @@ +from typing import Final + +import pytest + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.formats.bitphase.builder import build_bitphase +from sampletones_core.project.project import Project +from sampletones_core.project.voices.note_on import NoteOn +from sampletones_tools.corpus.build import Corpus +from sampletones_tools.corpus.module import ModuleConfig +from sampletones_tools.corpus.song import ChannelSpec, RowSpec, SongSpec +from sampletones_tools.tracker_playback.corpus import build +from sampletones_tools.tracker_playback.corpus.build import ( + arrangement_project, + comparison_corpus, + written_project, +) +from sampletones_tools.tracker_playback.corpus.spec import ( + ArrangementSpec, + CorpusSpec, + FrameRun, + ProjectSpec, + SampleSpec, +) +from sampletones_tools.tracker_playback.trace.application import application_trace + +MODULE: Final = ModuleConfig(title="Tone", author="Someone", tempo=150, speed=6, nes_frequency=60) +TONE: Final = SampleSpec( + kind="sample", + channels={ + ChannelName.PULSE1: [ + FrameRun(count=4, frame={"on": True, "pitch": 60, "volume": 15, "duty_cycle": 2}), + ] + }, +) +PROJECT: Final = ProjectSpec( + name="tone", + purpose="A tone.", + module=MODULE, + voices=["tone"], + song=SongSpec( + rows_per_pattern=2, + order=[{ChannelName.PULSE1: 0}], + channels={ChannelName.PULSE1: ChannelSpec(patterns={0: [RowSpec(row=0, voice="tone", volume=12)]})}, + ), +) +ARRANGEMENT: Final = ArrangementSpec(name="arrangement-groove", purpose="The arrangement, faster.", tempo=210) +CORPUS: Final = CorpusSpec(voices={"tone": TONE}, projects=[PROJECT], arrangements=[ARRANGEMENT]) + + +class TestWrittenProject: + def test_every_shipped_project_builds_exports_and_plays(self) -> None: + corpus = CorpusSpec.load() + + for spec in corpus.projects: + project = written_project(spec, corpus).project + build_bitphase(project) + assert application_trace(project).ticks > 0, spec.name + + def test_the_project_holds_the_voices_it_lists_and_plays_its_song(self) -> None: + written = written_project(PROJECT, CORPUS) + + project = written.project + assert (written.name, written.purpose) == ("tone", "A tone.") + assert [voice.name for voice in project.voices] == ["tone"] + row = project.song.channels[ChannelName.PULSE1].patterns[0].rows[0] + assert row.command == NoteOn(voice_id=project.voices[0].id) + assert row.volume == 12 + assert (project.settings.tempo, project.settings.speed) == (MODULE.tempo, MODULE.speed) + + def test_each_project_holds_voices_of_its_own(self) -> None: + first = written_project(PROJECT, CORPUS).project + second = written_project(PROJECT, CORPUS).project + + assert first.voices[0] is not second.voices[0] + + def test_a_voice_the_corpus_lacks_is_refused(self) -> None: + with pytest.raises(KeyError, match="lead"): + written_project(PROJECT.model_copy(update={"voices": ["lead"]}), CORPUS) + + +class TestArrangementProject: + def test_the_arrangement_plays_at_the_tempo_the_spec_names(self) -> None: + arrangement = written_project(PROJECT, CORPUS).project + + played = arrangement_project(ARRANGEMENT, arrangement) + + assert (played.name, played.purpose) == (ARRANGEMENT.name, ARRANGEMENT.purpose) + assert played.project.settings.tempo == ARRANGEMENT.tempo + assert arrangement.settings.tempo == MODULE.tempo + + +class TestComparisonCorpus: + def test_the_written_projects_come_first_then_the_arrangement_at_each_tempo( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + arrangement: Project = written_project(PROJECT, CORPUS).project + monkeypatch.setattr(build, "build_synthetic_corpus", lambda: Corpus(catalog={}, project=arrangement)) + + projects = comparison_corpus(CORPUS) + + assert [project.name for project in projects] == ["tone", "arrangement-groove"] + assert projects[1].project.song is arrangement.song diff --git a/tests/unit/sampletones_tools/tracker_playback/corpus/test_spec.py b/tests/unit/sampletones_tools/tracker_playback/corpus/test_spec.py new file mode 100644 index 000000000..e0c03b7d1 --- /dev/null +++ b/tests/unit/sampletones_tools/tracker_playback/corpus/test_spec.py @@ -0,0 +1,22 @@ +from sampletones_tools.tracker_playback.corpus.spec import CorpusSpec + + +class TestCorpusSpec: + def test_the_shipped_corpus_loads_from_the_package(self) -> None: + corpus = CorpusSpec.load() + + assert corpus.projects + assert corpus.arrangements + + def test_every_project_and_arrangement_writes_its_files_under_a_name_of_its_own(self) -> None: + corpus = CorpusSpec.load() + names = [project.name for project in corpus.projects] + [ + arrangement.name for arrangement in corpus.arrangements + ] + + assert len(set(names)) == len(names) + + def test_every_voice_a_project_holds_is_a_corpus_voice(self) -> None: + corpus = CorpusSpec.load() + + assert {voice for project in corpus.projects for voice in project.voices} <= set(corpus.voices) diff --git a/tests/unit/sampletones_tools/tracker_playback/corpus/test_voices.py b/tests/unit/sampletones_tools/tracker_playback/corpus/test_voices.py new file mode 100644 index 000000000..d57b694cc --- /dev/null +++ b/tests/unit/sampletones_tools/tracker_playback/corpus/test_voices.py @@ -0,0 +1,65 @@ +from typing import Final + +import pytest +from pydantic import ValidationError + +from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.features.envelope import Envelope +from sampletones_core.instructions import NoiseInstruction, PulseInstruction, TriangleInstruction +from sampletones_core.project.voices.envelopes import InstrumentEnvelopes +from sampletones_core.project.voices.instrument import Instrument +from sampletones_core.project.voices.sample import Sample +from sampletones_tools.tracker_playback.corpus.spec import FrameRun, InstrumentSpec, SampleSpec +from sampletones_tools.tracker_playback.corpus.voices import build_voice, channel_frames + +PULSE_FRAME: Final = PulseInstruction(on=True, pitch=60, volume=15, duty_cycle=2) +REST_FRAME: Final = PulseInstruction(on=False, pitch=60, volume=0, duty_cycle=2) + + +class TestChannelFrames: + def test_each_run_repeats_its_frame_in_order(self) -> None: + runs = [ + FrameRun(count=2, frame=PULSE_FRAME.model_dump()), + FrameRun(count=1, frame=REST_FRAME.model_dump()), + ] + + assert channel_frames(ChannelName.PULSE1, runs) == [PULSE_FRAME, PULSE_FRAME, REST_FRAME] + + def test_a_frame_is_read_as_the_instruction_its_channel_takes(self) -> None: + triangle = channel_frames(ChannelName.TRIANGLE, [FrameRun(count=1, frame={"on": True, "pitch": 48})]) + noise = channel_frames( + ChannelName.NOISE, + [FrameRun(count=1, frame={"on": True, "period": 3, "volume": 9, "short": True})], + ) + + assert triangle == [TriangleInstruction(on=True, pitch=48)] + assert noise == [NoiseInstruction(on=True, period=3, volume=9, short=True)] + + def test_a_frame_the_channel_cannot_play_is_refused(self) -> None: + with pytest.raises(ValidationError): + channel_frames(ChannelName.NOISE, [FrameRun(count=1, frame=PULSE_FRAME.model_dump())]) + + +class TestBuildVoice: + def test_a_sample_plays_the_frames_it_writes_on_its_channels(self) -> None: + spec = SampleSpec( + kind="sample", + channels={ChannelName.PULSE2: [FrameRun(count=3, frame=PULSE_FRAME.model_dump())]}, + ) + + voice = build_voice("tone", spec) + + assert isinstance(voice, Sample) + assert voice.name == "tone" + assert voice.reconstruction.playing_channels == (ChannelName.PULSE2,) + assert list(voice.reconstruction.instructions[ChannelName.PULSE2]) == [PULSE_FRAME] * 3 + + def test_an_instrument_carries_its_envelopes_and_references(self) -> None: + envelopes = InstrumentEnvelopes(volume=Envelope[int](items=(15, 0))) + spec = InstrumentSpec(kind="instrument", initial_pitch=57, initial_period=4, envelopes=envelopes) + + voice = build_voice("pluck", spec) + + assert isinstance(voice, Instrument) + assert (voice.name, voice.initial_pitch, voice.initial_period) == ("pluck", 57, 4) + assert voice.envelopes.envelope(FeatureKey.VOLUME).items == (15, 0) diff --git a/tests/unit/sampletones_tools/tracker_playback/targets/__init__.py b/tests/unit/sampletones_tools/tracker_playback/targets/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_tools/tracker_playback/targets/bitphase/__init__.py b/tests/unit/sampletones_tools/tracker_playback/targets/bitphase/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_tools/tracker_playback/targets/bitphase/test_engine.py b/tests/unit/sampletones_tools/tracker_playback/targets/bitphase/test_engine.py new file mode 100644 index 000000000..c1ae23ac9 --- /dev/null +++ b/tests/unit/sampletones_tools/tracker_playback/targets/bitphase/test_engine.py @@ -0,0 +1,118 @@ +import json +import subprocess +from pathlib import Path +from typing import Any, Final, List, Optional, Sequence + +import pytest + +from sampletones_tools.tracker_playback.paths import BITPHASE_TRACE_SCRIPT_PATH +from sampletones_tools.tracker_playback.targets.bitphase import engine +from sampletones_tools.tracker_playback.targets.bitphase.engine import ( + CHECKOUT_FILES, + TSX_CLI, + BitphaseCheckout, + BitphaseEngine, + EngineError, +) + +NODE: Final[Path] = Path("node") +ONE_SILENT_TICK: Final[str] = json.dumps({"ticks": [{"frame": 0, "row": 0, "writes": []}]}) + + +def _checkout(root: Path) -> BitphaseCheckout: + for relative in CHECKOUT_FILES: + (root / relative).parent.mkdir(parents=True, exist_ok=True) + (root / relative).touch() + + return BitphaseCheckout.located(root) + + +class TestBitphaseCheckout: + def test_a_checkout_holding_every_file_the_trace_loads_is_located(self, tmp_path: Path) -> None: + assert _checkout(tmp_path).root == tmp_path + + def test_a_checkout_without_its_packages_is_refused_naming_what_it_lacks(self, tmp_path: Path) -> None: + _checkout(tmp_path) + (tmp_path / TSX_CLI).unlink() + + with pytest.raises(EngineError, match="pnpm install") as refusal: + BitphaseCheckout.located(tmp_path) + + assert str(TSX_CLI) in str(refusal.value) + + +class TestBitphaseEngine: + def test_an_absent_node_is_reported(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + _checkout(tmp_path) + monkeypatch.setattr(engine, "locate_program", lambda program: None) + + with pytest.raises(EngineError, match="node"): + BitphaseEngine.located(tmp_path) + + def test_the_engine_takes_the_node_this_system_has(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + _checkout(tmp_path) + monkeypatch.setattr(engine, "locate_program", lambda program: Path("/usr/bin") / program) + + located = BitphaseEngine.located(tmp_path) + + assert located.node == Path("/usr/bin") / "node" + + def test_the_trace_script_runs_through_the_checkouts_own_tsx(self, tmp_path: Path) -> None: + played = BitphaseEngine(node=NODE, checkout=_checkout(tmp_path)) + document = tmp_path / "song.btp" + output = tmp_path / "song.json" + + assert played.command(document, output) == [ + str(NODE), + str(tmp_path / TSX_CLI), + str(BITPHASE_TRACE_SCRIPT_PATH), + str(tmp_path), + str(document), + str(output), + ] + + def test_the_script_is_handed_absolute_paths(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + played = BitphaseEngine(node=NODE, checkout=_checkout(tmp_path / "checkout")) + monkeypatch.chdir(tmp_path) + + command = played.command(Path("run") / "song.btp", Path("run") / "song.json") + + assert [Path(argument) for argument in command[-2:]] == [ + tmp_path / "run" / "song.btp", + tmp_path / "run" / "song.json", + ] + + def test_a_trace_is_read_from_the_file_the_run_writes( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + ) -> None: + played = BitphaseEngine(node=NODE, checkout=_checkout(tmp_path)) + output = tmp_path / "song.json" + runs: List[Optional[Path]] = [] + + def run(command: Sequence[str], *, cwd: Path, **options: Any) -> None: + runs.append(cwd) + Path(command[-1]).write_text(ONE_SILENT_TICK, encoding="utf-8") + + monkeypatch.setattr(engine.subprocess, "run", run) + + trace = played.trace(tmp_path / "song.btp", output) + + assert runs == [tmp_path] + assert trace.ticks == 1 + + def test_a_failed_run_is_reported_with_what_it_printed( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + ) -> None: + played = BitphaseEngine(node=NODE, checkout=_checkout(tmp_path)) + + def run(command: Sequence[str], **options: Any) -> None: + raise subprocess.CalledProcessError(1, list(command), stderr="Song is empty") + + monkeypatch.setattr(engine.subprocess, "run", run) + + with pytest.raises(EngineError, match="Song is empty"): + played.trace(tmp_path / "song.btp", tmp_path / "song.json") 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 new file mode 100644 index 000000000..1ed0a0fab --- /dev/null +++ b/tests/unit/sampletones_tools/tracker_playback/targets/bitphase/test_target.py @@ -0,0 +1,80 @@ +import gzip +import json +from pathlib import Path +from typing import Any, Final, List, Sequence + +import pytest + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.project.settings import ProjectSettings +from sampletones_shared.paths.extensions import EXT_FILE_BITPHASE, EXT_FILE_JSON +from sampletones_tools.tracker_playback.targets.bitphase import engine +from sampletones_tools.tracker_playback.targets.bitphase.engine import ( + CHECKOUT_FILES, + BitphaseCheckout, + BitphaseEngine, +) +from sampletones_tools.tracker_playback.targets.bitphase.target import TITLE, BitphaseTarget +from tests.suite.performance import make_pulse_reconstruction, place_instrument, project_with_sample + +NAME: Final[str] = "tone" + + +def _target(root: Path) -> BitphaseTarget: + for relative in CHECKOUT_FILES: + (root / relative).parent.mkdir(parents=True, exist_ok=True) + (root / relative).touch() + + return BitphaseTarget(engine=BitphaseEngine(node=Path("node"), checkout=BitphaseCheckout.located(root))) + + +class TestBitphaseTarget: + def test_the_report_names_bitphase_and_the_checkout_that_plays(self, tmp_path: Path) -> None: + target = _target(tmp_path) + + assert target.title == TITLE + assert str(tmp_path) in target.player + + def test_the_target_takes_the_node_this_system_has( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + ) -> None: + _target(tmp_path) + monkeypatch.setattr(engine, "locate_program", lambda program: Path("bin") / program) + + located = BitphaseTarget.located(tmp_path) + + assert located.engine.checkout.root == tmp_path + + def test_a_project_is_exported_and_its_document_played_beside_the_trace( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + ) -> None: + project, sample = project_with_sample( + make_pulse_reconstruction(count=4), + rows_per_pattern=1, + settings=ProjectSettings(tempo=150, speed=6, nes_frequency=60), + ) + place_instrument(project, channel_name=ChannelName.PULSE1, row_index=0, sample=sample, volume=15) + played: List[Sequence[str]] = [] + + def run(command: Sequence[str], **options: Any) -> None: + played.append(command) + ticks = [{"frame": 0, "row": 0, "writes": []}] + Path(command[-1]).write_text(json.dumps({"ticks": ticks}), encoding="utf-8") + + monkeypatch.setattr(engine.subprocess, "run", run) + documents = tmp_path / "documents" + documents.mkdir() + + playback = _target(tmp_path / "checkout").play(project, documents, NAME) + + assert playback.document == documents / f"{NAME}{EXT_FILE_BITPHASE}" + assert json.loads(gzip.decompress(playback.document.read_bytes())) + assert [command[-2:] for command in played] == [ + [str(playback.document), str(documents / f"{NAME}{EXT_FILE_JSON}")], + ] + assert playback.trace.ticks == 1 + assert (playback.skipped_rows, playback.truncation) == (0, None) diff --git a/tests/unit/sampletones_tools/tracker_playback/targets/bitphase/test_trace.py b/tests/unit/sampletones_tools/tracker_playback/targets/bitphase/test_trace.py new file mode 100644 index 000000000..1b24d7195 --- /dev/null +++ b/tests/unit/sampletones_tools/tracker_playback/targets/bitphase/test_trace.py @@ -0,0 +1,183 @@ +import json +from dataclasses import dataclass +from typing import Any, Dict, Final, List, Sequence, Tuple + +import pytest +from pydantic import ValidationError + +from sampletones_core.constants.enums import ChannelName +from sampletones_player.specification.registers import ( + APU_STATUS, + DUTY_CYCLE_SHIFT, + MAX_REGISTER_VALUE, + NOISE_CONTROL, + NOISE_PERIOD, + PULSE1_CONTROL, + PULSE1_TIMER_HIGH, + PULSE1_TIMER_LOW, + PULSE2_CONTROL, + SUSTAINED_LEVEL, + TIMER_HIGH_SHIFT, + TRIANGLE_LINEAR_COUNTER, + TRIANGLE_TIMER_HIGH, + TRIANGLE_TIMER_LOW, +) +from sampletones_tools.player.trace.write import RegisterWrite +from sampletones_tools.tracker_playback.targets.bitphase.trace import ( + UNIT_STATUS_BITS, + EmulatorUnit, + UnitWrite, + read_engine_trace, +) +from sampletones_tools.tracker_playback.trace.sound import ABSENT_REGISTER, ChannelSound, TickPosition +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + +PULSE_STATUS: Final[int] = 0x03 +TRIANGLE_NOISE_STATUS: Final[int] = 0x0C +EVERY_STATUS_BIT: Final[int] = 0x1F +LENGTH_INDEX_BITS: Final[int] = 0x78 +PULSE_TIMER: Final[int] = 427 +PULSE_LEVEL: Final[int] = 15 +PULSE_DUTY: Final[int] = 2 +TRIANGLE_TIMER: Final[int] = 854 +NOISE_LEVEL: Final[int] = 14 +NOISE_REGISTER_PERIOD: Final[int] = 9 +SOUNDING_PULSE: Final[int] = (PULSE_DUTY << DUTY_CYCLE_SHIFT) | SUSTAINED_LEVEL | PULSE_LEVEL +SOUNDING_TRIANGLE: Final[int] = 0xFF +SOUNDING_NOISE: Final[int] = SUSTAINED_LEVEL | NOISE_LEVEL + +Write = Tuple[EmulatorUnit, int, int] + +ENABLING: Final[Tuple[Write, ...]] = ( + (EmulatorUnit.APU, APU_STATUS, PULSE_STATUS), + (EmulatorUnit.DMC, APU_STATUS, TRIANGLE_NOISE_STATUS), +) +PULSE_WRITES: Final[Tuple[Write, ...]] = ( + (EmulatorUnit.APU, PULSE1_CONTROL, SOUNDING_PULSE), + (EmulatorUnit.APU, PULSE1_TIMER_LOW, PULSE_TIMER & MAX_REGISTER_VALUE), + (EmulatorUnit.APU, PULSE1_TIMER_HIGH, LENGTH_INDEX_BITS | (PULSE_TIMER >> TIMER_HIGH_SHIFT)), +) +EVERY_CHANNEL_SOUNDING: Final[Tuple[Write, ...]] = ( + *ENABLING, + *PULSE_WRITES, + (EmulatorUnit.APU, PULSE2_CONTROL, SOUNDING_PULSE), + (EmulatorUnit.DMC, TRIANGLE_LINEAR_COUNTER, SOUNDING_TRIANGLE), + (EmulatorUnit.DMC, TRIANGLE_TIMER_LOW, TRIANGLE_TIMER & MAX_REGISTER_VALUE), + (EmulatorUnit.DMC, TRIANGLE_TIMER_HIGH, LENGTH_INDEX_BITS | (TRIANGLE_TIMER >> TIMER_HIGH_SHIFT)), + (EmulatorUnit.DMC, NOISE_CONTROL, SOUNDING_NOISE), + (EmulatorUnit.DMC, NOISE_PERIOD, NOISE_REGISTER_PERIOD), +) + + +def _tick(*writes: Write, frame: int = 0, row: int = 0) -> Dict[str, Any]: + return { + "frame": frame, + "row": row, + "writes": [{"unit": unit.value, "address": address, "value": value} for unit, address, value in writes], + } + + +def _trace_text(ticks: Sequence[Dict[str, Any]]) -> str: + return json.dumps({"ticks": list(ticks)}) + + +def _audible(ticks: Sequence[Dict[str, Any]]) -> Dict[ChannelName, List[bool]]: + trace = read_engine_trace(_trace_text(ticks)) + return {channel: [sound.audible for sound in sounds] for channel, sounds in trace.channels.items()} + + +class TestUnitWrite(BaseTestSuite): + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + unit: EmulatorUnit + status: int + value: int + expected: int + + test_cases: Tuple[TestCase, ...] = ( + TestCase( + label="the pulse unit keeps the others' bits", unit=EmulatorUnit.APU, status=0x0C, value=0x1F, expected=0x0F + ), + TestCase( + label="the pulse unit clears its own bits", unit=EmulatorUnit.APU, status=0x0F, value=0x00, expected=0x0C + ), + TestCase( + label="the dmc unit keeps the pulse bits", unit=EmulatorUnit.DMC, status=0x03, value=0x03, expected=0x03 + ), + TestCase(label="the dmc unit sets its own bits", unit=EmulatorUnit.DMC, status=0x03, value=0x1C, expected=0x1F), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label) + def test_a_status_write_reaches_the_bits_of_the_channels_its_unit_plays(self, test_case: TestCase) -> None: + write = UnitWrite(unit=test_case.unit, address=APU_STATUS, value=test_case.value) + + assert write.chip_write(test_case.status) == RegisterWrite(APU_STATUS, test_case.expected) + + def test_the_units_share_the_status_register_between_them(self) -> None: + assert UNIT_STATUS_BITS[EmulatorUnit.APU] | UNIT_STATUS_BITS[EmulatorUnit.DMC] == EVERY_STATUS_BIT + assert not UNIT_STATUS_BITS[EmulatorUnit.APU] & UNIT_STATUS_BITS[EmulatorUnit.DMC] + + @pytest.mark.parametrize("unit", tuple(EmulatorUnit), ids=str) + def test_a_channel_register_write_reaches_the_chip_as_it_is(self, unit: EmulatorUnit) -> None: + write = UnitWrite(unit=unit, address=NOISE_CONTROL, value=SOUNDING_NOISE) + + assert write.chip_write(EVERY_STATUS_BIT) == RegisterWrite(NOISE_CONTROL, SOUNDING_NOISE) + + +class TestReadEngineTrace: + def test_each_tick_keeps_the_frame_and_row_bitphase_played_it_at(self) -> None: + trace = read_engine_trace(_trace_text((_tick(frame=2, row=5),))) + + assert trace.positions == (TickPosition(frame=2, row=5),) + assert set(trace.channels) == set(ChannelName.items()) + + def test_every_channel_is_read_from_the_registers_the_engine_wrote(self) -> None: + trace = read_engine_trace(_trace_text((_tick(*EVERY_CHANNEL_SOUNDING),))) + + assert trace.channels[ChannelName.PULSE1] == ( + ChannelSound(audible=True, period=PULSE_TIMER, volume=PULSE_LEVEL, held=True, timbre=PULSE_DUTY), + ) + assert trace.channels[ChannelName.TRIANGLE] == ( + ChannelSound( + audible=True, period=TRIANGLE_TIMER, volume=ABSENT_REGISTER, held=True, timbre=ABSENT_REGISTER + ), + ) + assert trace.channels[ChannelName.NOISE] == ( + ChannelSound(audible=True, period=NOISE_REGISTER_PERIOD, volume=NOISE_LEVEL, held=True, timbre=0), + ) + + def test_a_register_written_on_one_tick_stands_on_the_ticks_after(self) -> None: + trace = read_engine_trace(_trace_text((_tick(*ENABLING, *PULSE_WRITES), _tick(), _tick()))) + + assert len(set(trace.channels[ChannelName.PULSE1])) == 1 + assert trace.channels[ChannelName.PULSE1][-1].audible + + def test_each_unit_enables_the_channels_it_plays(self) -> None: + audible = _audible( + ( + _tick(*EVERY_CHANNEL_SOUNDING), + _tick((EmulatorUnit.DMC, APU_STATUS, 0x00)), + _tick((EmulatorUnit.APU, APU_STATUS, 0x00), (EmulatorUnit.DMC, APU_STATUS, TRIANGLE_NOISE_STATUS)), + ) + ) + + assert audible == { + ChannelName.PULSE1: [True, True, False], + ChannelName.PULSE2: [True, True, False], + ChannelName.TRIANGLE: [True, False, True], + ChannelName.NOISE: [True, False, True], + } + + @pytest.mark.parametrize( + "write", + ( + {"unit": "apu", "address": 0x3FFF, "value": 0}, + {"unit": "apu", "address": APU_STATUS, "value": 0x100}, + {"unit": "mmc5", "address": APU_STATUS, "value": 0}, + ), + ids=("below the apu", "wider than a byte", "no unit of the emulator"), + ) + def test_a_write_the_script_never_records_is_refused(self, write: Dict[str, Any]) -> None: + with pytest.raises(ValidationError): + read_engine_trace(json.dumps({"ticks": [{"frame": 0, "row": 0, "writes": [write]}]})) diff --git a/tests/unit/sampletones_tools/tracker_playback/test_command.py b/tests/unit/sampletones_tools/tracker_playback/test_command.py new file mode 100644 index 000000000..3f861c27f --- /dev/null +++ b/tests/unit/sampletones_tools/tracker_playback/test_command.py @@ -0,0 +1,105 @@ +from pathlib import Path +from typing import Final, List, Sequence + +import pytest + +from sampletones.commands.registry import COMMANDS +from sampletones.dispatcher import dispatch +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.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 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" +DEFAULT_OUTPUT: Final[str] = "sampletones_tools.tracker_playback.session.default_output" + + +def _matching_outcome(name: str) -> ProjectOutcome: + return ProjectOutcome( + project=CorpusProject(name=name, purpose="A tone.", project=Project.create()), + comparison=TraceComparison(application_ticks=6, engine_ticks=6, timing=None, divergences=()), + skipped_rows=0, + truncation=None, + ) + + +@pytest.fixture(name="runs") +def runs_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] = [] + + def check_corpus(projects: Sequence[CorpusProject], run: PlaybackRun) -> PlaybackOutcome: + runs.append(run) + 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 + + +class TestTrackerPlayback: + def test_each_verdict_and_the_report_link_are_printed( + self, + runs: List[PlaybackRun], + replaying_target: ReplayingTarget, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + ) -> None: + arguments = [COMMAND, "bitphase", "--checkout", str(tmp_path), "-o", str(tmp_path / "run")] + + assert dispatch(COMMANDS, arguments) == 0 + (run,) = runs + assert (run.target, run.output) == (replaying_target, tmp_path / "run") + assert run.settings.examples_per_difference >= 1 + assert capsys.readouterr().out.splitlines() == [ + f"tone: {MATCHES}", + f"Report: {(tmp_path / 'report.md').resolve().as_uri()}", + ] + + def test_a_run_given_no_output_writes_where_the_default_names( + self, + runs: List[PlaybackRun], + 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"] + + def test_a_tracker_that_cannot_run_is_reported( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + ) -> None: + def located(cls: type, root: Path) -> BitphaseTarget: + raise EngineError("node is absent") + + monkeypatch.setattr(BitphaseTarget, "located", classmethod(located)) + + with pytest.raises(SystemExit, match="node is absent"): + dispatch(COMMANDS, [COMMAND, "bitphase", "--checkout", str(tmp_path)]) + + def test_the_bitphase_checkout_is_required(self) -> None: + with pytest.raises(SystemExit) as leaving: + dispatch(COMMANDS, [COMMAND, "bitphase"]) + + assert leaving.value.code == 2 + + def test_a_target_is_required(self) -> None: + with pytest.raises(SystemExit) as leaving: + dispatch(COMMANDS, [COMMAND]) + + assert leaving.value.code == 2 diff --git a/tests/unit/sampletones_tools/tracker_playback/test_comparison.py b/tests/unit/sampletones_tools/tracker_playback/test_comparison.py new file mode 100644 index 000000000..802f832ca --- /dev/null +++ b/tests/unit/sampletones_tools/tracker_playback/test_comparison.py @@ -0,0 +1,169 @@ +from dataclasses import dataclass, replace +from typing import Dict, Final, Sequence, Tuple + +import pytest + +from sampletones_core.constants.enums import ChannelName +from sampletones_tools.tracker_playback.comparison import ( + SoundField, + compare_traces, + differing_fields, +) +from sampletones_tools.tracker_playback.trace.sound import ChannelSound, SongTrace, TickPosition +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + +TONE: Final[ChannelSound] = ChannelSound(audible=True, period=427, volume=15, held=True, timbre=2) +SILENT: Final[ChannelSound] = ChannelSound(audible=False, period=0, volume=0, held=True, timbre=0) +ROW_TICKS: Final[int] = 2 +EXAMPLES: Final[int] = 3 + + +def _trace(pulse: Sequence[ChannelSound], rows: Sequence[int]) -> SongTrace: + """A trace sounding ``pulse`` on the first pulse channel, the rest silent, a row every ``ROW_TICKS``.""" + positions = tuple(TickPosition(frame=0, row=row) for row in rows) + channels: Dict[ChannelName, Tuple[ChannelSound, ...]] = { + channel: tuple(pulse) if channel == ChannelName.PULSE1 else (SILENT,) * len(positions) + for channel in ChannelName.items() + } + return SongTrace(positions=positions, channels=channels) + + +def _rows(ticks: int) -> Tuple[int, ...]: + return tuple(tick // ROW_TICKS for tick in range(ticks)) + + +class TestDifferingFields(BaseTestSuite): + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + application: ChannelSound + engine: ChannelSound + expected: Tuple[SoundField, ...] + + test_cases: Tuple[TestCase, ...] = ( + TestCase(label="alike", application=TONE, engine=TONE, expected=()), + TestCase( + label="silent on both sides whatever the registers hold", + application=replace(SILENT, period=2047), + engine=replace(SILENT, period=5, volume=3, held=False), + expected=(), + ), + TestCase( + label="sounding on one side alone", + application=TONE, + engine=replace(TONE, audible=False, period=5), + expected=(SoundField.AUDIBLE,), + ), + TestCase( + label="every register", + application=TONE, + engine=ChannelSound(audible=True, period=428, volume=5, held=True, timbre=1), + expected=(SoundField.PERIOD, SoundField.VOLUME, SoundField.TIMBRE), + ), + TestCase( + label="the volume alone", + application=TONE, + engine=replace(TONE, volume=5), + expected=(SoundField.VOLUME,), + ), + TestCase( + label="a level the chip counts down against one it holds", + application=TONE, + engine=replace(TONE, held=False), + expected=(SoundField.VOLUME,), + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label) + def test_the_fields_that_differ(self, test_case: TestCase) -> None: + assert differing_fields(test_case.application, test_case.engine) == test_case.expected + + +class TestCompareTraces: + def test_alike_traces_match(self) -> None: + trace = _trace((TONE,) * 4, _rows(4)) + + comparison = compare_traces(trace, trace, examples=EXAMPLES) + + assert comparison.matches + assert (comparison.application_ticks, comparison.engine_ticks) == (4, 4) + + def test_ticks_differing_in_the_same_fields_share_a_divergence_counting_them(self) -> None: + application = _trace((TONE,) * 4, _rows(4)) + engine = _trace((TONE, replace(TONE, volume=5), replace(TONE, volume=5), replace(TONE, volume=4)), _rows(4)) + + comparison = compare_traces(application, engine, examples=EXAMPLES) + + assert not comparison.matches + (divergence,) = comparison.divergences + assert (divergence.channel, divergence.fields, divergence.ticks) == ( + ChannelName.PULSE1, + (SoundField.VOLUME,), + 3, + ) + assert divergence.first.tick == 1 + assert divergence.first.position == TickPosition(frame=0, row=0) + assert (divergence.first.application, divergence.first.engine) == (TONE, replace(TONE, volume=5)) + + def test_a_later_row_sounding_new_values_joins_the_examples(self) -> None: + application = _trace((TONE,) * 6, _rows(6)) + engine = _trace( + ( + replace(TONE, period=428), + replace(TONE, period=428), + replace(TONE, period=428), + replace(TONE, period=428), + replace(TONE, period=500), + replace(TONE, period=501), + ), + _rows(6), + ) + + (divergence,) = compare_traces(application, engine, examples=EXAMPLES).divergences + + assert [example.tick for example in divergence.examples] == [0, 4] + assert divergence.ticks == 6 + + def test_the_examples_stop_at_their_limit(self) -> None: + ticks = 2 * EXAMPLES * ROW_TICKS + application = _trace((TONE,) * ticks, _rows(ticks)) + engine = _trace(tuple(replace(TONE, period=tick) for tick in range(ticks)), _rows(ticks)) + + (divergence,) = compare_traces(application, engine, examples=EXAMPLES).divergences + + assert len(divergence.examples) == EXAMPLES + assert divergence.ticks == ticks + + def test_divergences_are_listed_in_the_order_they_first_show(self) -> None: + application = _trace((TONE,) * 4, _rows(4)) + engine = _trace((TONE, TONE, replace(TONE, volume=3), SILENT), _rows(4)) + + comparison = compare_traces(application, engine, examples=EXAMPLES) + + assert [divergence.fields for divergence in comparison.divergences] == [ + (SoundField.VOLUME,), + (SoundField.AUDIBLE,), + ] + + def test_a_song_bitphase_plays_longer_is_no_match(self) -> None: + comparison = compare_traces( + _trace((TONE,) * 2, _rows(2)), + _trace((TONE,) * 3, _rows(3)), + examples=EXAMPLES, + ) + + assert not comparison.divergences + assert comparison.timing is None + assert not comparison.matches + + def test_the_first_tick_the_rows_part_is_named(self) -> None: + application = _trace((TONE,) * 4, (0, 0, 1, 1)) + engine = _trace((TONE,) * 4, (0, 0, 0, 1)) + + comparison = compare_traces(application, engine, examples=EXAMPLES) + + assert comparison.timing is not None + assert comparison.timing.tick == 2 + assert comparison.timing.position == TickPosition(frame=0, row=1) + assert comparison.timing.engine_position == TickPosition(frame=0, row=0) + assert not comparison.matches diff --git a/tests/unit/sampletones_tools/tracker_playback/test_report.py b/tests/unit/sampletones_tools/tracker_playback/test_report.py new file mode 100644 index 000000000..8e1f5d00f --- /dev/null +++ b/tests/unit/sampletones_tools/tracker_playback/test_report.py @@ -0,0 +1,132 @@ +from dataclasses import replace +from typing import Final, Tuple + +from sampletones_core.constants.enums import ChannelName +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.report import ( + COUNTED_DOWN, + DIFFERS, + DIFFERS_ONCE, + LENGTHS_DIFFER, + LONG_MODE, + MATCHES, + NOISE_SOUND, + PULSE_SOUND, + SHORT_MODE, + SHORTENED, + SILENT, + SKIPPED_ROWS, + TIMING_DIFFERS, + TIMING_ONLY, + TRIANGLE_SOUND, + describe_sound, + report_text, + result_label, +) +from sampletones_tools.tracker_playback.trace.sound import ChannelSound, SongTrace, TickPosition +from tests.suite.playback import ReplayingTarget + +EXAMPLES: Final[int] = 4 +TONE: Final[ChannelSound] = ChannelSound(audible=True, period=427, volume=15, held=True, timbre=2) +QUIET: Final[ChannelSound] = replace(TONE, volume=5) +SILENT_SOUND: Final[ChannelSound] = ChannelSound(audible=False, period=0, volume=0, held=True, timbre=0) + + +def _trace(pulse: Tuple[ChannelSound, ...]) -> SongTrace: + return SongTrace( + positions=tuple(TickPosition(frame=0, row=tick) for tick in range(len(pulse))), + channels={ + channel: pulse if channel == ChannelName.PULSE1 else (SILENT_SOUND,) * len(pulse) + for channel in ChannelName.items() + }, + ) + + +def _outcome(name: str, comparison: TraceComparison) -> ProjectOutcome: + return ProjectOutcome( + project=CorpusProject(name=name, purpose=f"What {name} exercises.", project=Project.create()), + comparison=comparison, + skipped_rows=0, + truncation=None, + ) + + +class TestDescribeSound: + def test_a_silent_channel_reads_silent_on_any_channel(self) -> None: + assert {describe_sound(channel, SILENT_SOUND) for channel in ChannelName.items()} == {SILENT} + + def test_each_channel_names_the_registers_it_has(self) -> None: + short = ChannelSound(audible=True, period=9, volume=12, held=True, timbre=1) + + assert describe_sound(ChannelName.PULSE2, TONE) == PULSE_SOUND.format(period=427, volume=15, timbre=2) + assert describe_sound(ChannelName.TRIANGLE, TONE) == TRIANGLE_SOUND.format(period=427) + assert describe_sound(ChannelName.NOISE, short) == NOISE_SOUND.format(period=9, volume=12, mode=SHORT_MODE) + assert describe_sound(ChannelName.NOISE, replace(short, timbre=0)) == NOISE_SOUND.format( + period=9, + volume=12, + mode=LONG_MODE, + ) + + def test_a_sound_the_chip_counts_down_says_so_on_every_channel(self) -> None: + counted_down = replace(TONE, held=False) + + for channel in ChannelName.items(): + assert describe_sound(channel, counted_down) == describe_sound(channel, TONE) + COUNTED_DOWN + + +class TestReportText: + def test_the_summary_names_every_project_with_its_verdict(self, replaying_target: ReplayingTarget) -> None: + alike = compare_traces(_trace((TONE, TONE)), _trace((TONE, TONE)), examples=EXAMPLES) + different = compare_traces(_trace((TONE, TONE)), _trace((TONE, QUIET)), examples=EXAMPLES) + + text = report_text(replaying_target, (_outcome("alike", alike), _outcome("quiet", different))) + + assert replaying_target.title in text + assert replaying_target.player in text + assert f"| alike | 2 | 2 | {MATCHES} |" in text + assert f"| quiet | 2 | 2 | {DIFFERS_ONCE} |" in text + assert "What quiet exercises." in text + + def test_a_divergence_lists_both_sides_where_it_first_shows(self, replaying_target: ReplayingTarget) -> None: + different = compare_traces(_trace((TONE, TONE)), _trace((TONE, QUIET)), examples=EXAMPLES) + + text = report_text(replaying_target, (_outcome("quiet", different),)) + + application = describe_sound(ChannelName.PULSE1, TONE) + engine = describe_sound(ChannelName.PULSE1, QUIET) + assert f"| pulse1 | volume | 1 | 0 | 1 | {application} | {engine} | 1 |" in text + + def test_the_lengths_the_timing_and_what_the_export_left_out_are_stated( + self, replaying_target: ReplayingTarget + ) -> None: + application = _trace((TONE, TONE)) + 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, + truncation=EnvelopeTruncation(frames=512, source_frames=601, instruments=1), + ) + + text = report_text(replaying_target, (outcome,)) + + 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 SHORTENED.format(instruments=1, frames=512, source_frames=601) in text + + +class TestResultLabel: + def test_a_project_differing_several_ways_counts_them(self) -> None: + silent = replace(TONE, audible=False) + comparison = compare_traces(_trace((TONE, TONE)), _trace((QUIET, silent)), examples=EXAMPLES) + + assert result_label(comparison) == DIFFERS.format(count=2) + + def test_a_project_whose_rows_alone_differ_says_so(self) -> None: + comparison = compare_traces(_trace((TONE, TONE)), _trace((TONE, TONE, TONE)), examples=EXAMPLES) + + assert result_label(comparison) == TIMING_ONLY diff --git a/tests/unit/sampletones_tools/tracker_playback/test_session.py b/tests/unit/sampletones_tools/tracker_playback/test_session.py new file mode 100644 index 000000000..d4fbd991e --- /dev/null +++ b/tests/unit/sampletones_tools/tracker_playback/test_session.py @@ -0,0 +1,53 @@ +from pathlib import Path +from typing import Final + +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.settings import PlaybackSettings +from tests.suite.performance import make_pulse_reconstruction, place_instrument, project_with_sample +from tests.suite.playback import ReplayingTarget + +NAME: Final[str] = "tone" +SETTINGS: Final[PlaybackSettings] = PlaybackSettings(examples_per_difference=2) + + +def _corpus_project() -> CorpusProject: + 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) + + +class TestCheckCorpus: + 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(),), + PlaybackRun(target=replaying_target, output=tmp_path, settings=SETTINGS), + ) + + assert replaying_target.played == [(tmp_path / DOCUMENTS_DIRECTORY_NAME, NAME)] + assert outcome.report == tmp_path / REPORT_FILENAME + assert replaying_target.title in outcome.report.read_text(encoding="utf-8") + (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) + + +class TestDefaultOutput: + def test_a_run_given_no_output_lands_under_the_tools_documents_directory(self) -> None: + assert default_output().parent == OUTPUT_ROOT diff --git a/tests/unit/sampletones_tools/tracker_playback/test_settings.py b/tests/unit/sampletones_tools/tracker_playback/test_settings.py new file mode 100644 index 000000000..82573e1e7 --- /dev/null +++ b/tests/unit/sampletones_tools/tracker_playback/test_settings.py @@ -0,0 +1,6 @@ +from sampletones_tools.tracker_playback.settings import PlaybackSettings + + +class TestPlaybackSettings: + def test_the_shipped_settings_load_from_the_package(self) -> None: + assert PlaybackSettings.load().examples_per_difference >= 1 diff --git a/tests/unit/sampletones_tools/tracker_playback/trace/__init__.py b/tests/unit/sampletones_tools/tracker_playback/trace/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_tools/tracker_playback/trace/test_application.py b/tests/unit/sampletones_tools/tracker_playback/trace/test_application.py new file mode 100644 index 000000000..a2252d894 --- /dev/null +++ b/tests/unit/sampletones_tools/tracker_playback/trace/test_application.py @@ -0,0 +1,121 @@ +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_player.specification.registers import ( + APU_STATUS, + CHANNELS_ENABLED, + PULSE1_CONTROL, + PULSE1_SWEEP, + PULSE2_SWEEP, + SWEEP_DISABLED, +) +from sampletones_tools.tracker_playback.trace.application import ( + application_trace, + driver_registers, + song_positions, +) +from sampletones_tools.tracker_playback.trace.sound import ChannelSound, TickPosition +from tests.suite.performance import ( + make_pulse_reconstruction, + place_instrument, + project_with_sample, +) +from tests.suite.player import ( + PLAYER_FULL_VOLUME, + PLAYER_OCTAVE_UP_TIMER, + PLAYER_REFERENCE_TIMER, + PLAYER_SILENT_VOLUME, + PLAYER_TIMER_TABLE, + pulse_tick, + resting_streams, +) + +TICKS_PER_ROW: Final[int] = 6 +ROWS_PER_PATTERN: Final[int] = 2 +SETTINGS: Final[ProjectSettings] = ProjectSettings(tempo=150, speed=TICKS_PER_ROW, nes_frequency=60) +PITCH: Final[int] = 60 +FRAMES: Final[int] = 8 +ROW_VOLUME: Final[int] = 5 + + +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) + + 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), + ) + + +class TestDriverRegisters: + SOUNDING: Final = pulse_tick(PLAYER_FULL_VOLUME, 0, PLAYER_REFERENCE_TIMER) + OCTAVE_UP: Final = pulse_tick(PLAYER_FULL_VOLUME, 0, PLAYER_OCTAVE_UP_TIMER) + RESTING: Final = pulse_tick(PLAYER_SILENT_VOLUME, 0, PLAYER_REFERENCE_TIMER) + + def test_the_console_stands_where_the_drivers_init_routine_leaves_it(self) -> None: + (registers,) = driver_registers(resting_streams((self.SOUNDING,)), 1) + + assert registers.value(APU_STATUS) == CHANNELS_ENABLED + assert (registers.value(PULSE1_SWEEP), registers.value(PULSE2_SWEEP)) == (SWEEP_DISABLED, SWEEP_DISABLED) + + def test_each_tick_holds_the_values_its_channels_write(self) -> None: + registers = driver_registers(resting_streams((self.SOUNDING, self.OCTAVE_UP)), 2) + + assert [tick.value(PULSE1_CONTROL) for tick in registers] == [self.SOUNDING.control, self.OCTAVE_UP.control] + + def test_a_channel_past_the_end_of_its_stream_holds_its_final_values(self) -> None: + registers = driver_registers(resting_streams((self.SOUNDING, self.RESTING)), 4) + + assert registers[-1] == registers[1] + + +class TestApplicationTrace: + def test_a_sample_sounds_its_frames_at_the_row_volume_and_then_rests(self) -> None: + project, sample = project_with_sample( + make_pulse_reconstruction(pitch=PITCH, count=FRAMES), + rows_per_pattern=ROWS_PER_PATTERN, + settings=SETTINGS, + ) + place_instrument( + project, + channel_name=ChannelName.PULSE1, + row_index=0, + sample=sample, + volume=ROW_VOLUME, + ) + + trace = application_trace(project) + + pulse = trace.channels[ChannelName.PULSE1] + assert trace.ticks == ROWS_PER_PATTERN * TICKS_PER_ROW + assert trace.positions[TICKS_PER_ROW] == TickPosition(frame=0, row=1) + assert pulse[0] == ChannelSound( + audible=True, + period=PLAYER_TIMER_TABLE[PITCH], + volume=ROW_VOLUME, + held=True, + timbre=0, + ) + assert all(sound.audible for sound in pulse[:FRAMES]) + assert not any(sound.audible for sound in pulse[FRAMES:]) + assert not any(sound.audible for sound in trace.channels[ChannelName.NOISE]) + + def test_every_channel_covers_every_tick_of_the_order(self) -> None: + project, _ = project_with_sample( + make_pulse_reconstruction(count=FRAMES), + rows_per_pattern=ROWS_PER_PATTERN, + settings=SETTINGS, + ) + project.song.append_frame() + + trace = application_trace(project) + + assert trace.ticks == 2 * ROWS_PER_PATTERN * TICKS_PER_ROW + assert {len(sounds) for sounds in trace.channels.values()} == {trace.ticks} diff --git a/tests/unit/sampletones_tools/tracker_playback/trace/test_decode.py b/tests/unit/sampletones_tools/tracker_playback/trace/test_decode.py new file mode 100644 index 000000000..98035a599 --- /dev/null +++ b/tests/unit/sampletones_tools/tracker_playback/trace/test_decode.py @@ -0,0 +1,222 @@ +from dataclasses import dataclass +from typing import Final, Tuple + +import pytest + +from sampletones_core.constants.enums import ChannelName +from sampletones_player.specification.registers import ( + APU_STATUS, + CHANNELS_ENABLED, + CONSTANT_VOLUME, + DUTY_CYCLE_SHIFT, + LENGTH_COUNTER_HALT, + MAX_REGISTER_VALUE, + NOISE_CONTROL, + NOISE_MODE_SHIFT, + NOISE_PERIOD, + PULSE1_CONTROL, + PULSE1_TIMER_HIGH, + PULSE1_TIMER_LOW, + PULSE2_CONTROL, + PULSE2_TIMER_HIGH, + PULSE2_TIMER_LOW, + SUSTAINED_LEVEL, + TIMER_HIGH_SHIFT, + TRIANGLE_COUNTER_CONTROL, + TRIANGLE_LINEAR_COUNTER, + TRIANGLE_TIMER_HIGH, + TRIANGLE_TIMER_LOW, +) +from sampletones_tools.player.trace.write import RegisterWrite +from sampletones_tools.tracker_playback.trace.decode import ( + CHANNEL_STATUS_BITS, + LINEAR_COUNTER_RELOAD, + channel_sound, + song_trace, +) +from sampletones_tools.tracker_playback.trace.registers import ChipRegisters +from sampletones_tools.tracker_playback.trace.sound import ABSENT_REGISTER, ChannelSound, TickPosition +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + +TIMER: Final[int] = 0x1AB +LENGTH_INDEX_BITS: Final[int] = 0xF8 +TIMER_LOW: Final[int] = TIMER & MAX_REGISTER_VALUE +TIMER_HIGH: Final[int] = LENGTH_INDEX_BITS | (TIMER >> TIMER_HIGH_SHIFT) +DUTY: Final[int] = 1 +LEVEL: Final[int] = 9 +NOISE_REGISTER_PERIOD: Final[int] = 6 +SOUNDING_PULSE: Final[int] = (DUTY << DUTY_CYCLE_SHIFT) | SUSTAINED_LEVEL | LEVEL +SOUNDING_TRIANGLE: Final[int] = TRIANGLE_COUNTER_CONTROL | LINEAR_COUNTER_RELOAD +SOUNDING_NOISE: Final[int] = SUSTAINED_LEVEL | LEVEL +SHORT_NOISE_PERIOD: Final[int] = (1 << NOISE_MODE_SHIFT) | NOISE_REGISTER_PERIOD + + +def _registers( + *writes: Tuple[int, int], + status: int = CHANNELS_ENABLED, +) -> ChipRegisters: + return ChipRegisters.power_up().written( + RegisterWrite(address, value) for address, value in ((APU_STATUS, status), *writes) + ) + + +def _every_channel_sounding(status: int) -> ChipRegisters: + return _registers( + (PULSE1_CONTROL, SOUNDING_PULSE), + (PULSE1_TIMER_LOW, TIMER_LOW), + (PULSE1_TIMER_HIGH, TIMER_HIGH), + (PULSE2_CONTROL, SOUNDING_PULSE), + (PULSE2_TIMER_LOW, TIMER_LOW), + (PULSE2_TIMER_HIGH, TIMER_HIGH), + (TRIANGLE_LINEAR_COUNTER, SOUNDING_TRIANGLE), + (TRIANGLE_TIMER_LOW, TIMER_LOW), + (TRIANGLE_TIMER_HIGH, TIMER_HIGH), + (NOISE_CONTROL, SOUNDING_NOISE), + (NOISE_PERIOD, SHORT_NOISE_PERIOD), + status=status, + ) + + +class TestPulseSound(BaseTestSuite): + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + channel: ChannelName + control: int + timer_low: int + timer_high: int + + test_cases: Tuple[TestCase, ...] = ( + TestCase( + label="pulse1", + channel=ChannelName.PULSE1, + control=PULSE1_CONTROL, + timer_low=PULSE1_TIMER_LOW, + timer_high=PULSE1_TIMER_HIGH, + ), + TestCase( + label="pulse2", + channel=ChannelName.PULSE2, + control=PULSE2_CONTROL, + timer_low=PULSE2_TIMER_LOW, + timer_high=PULSE2_TIMER_HIGH, + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label) + def test_a_pulse_reads_its_duty_its_level_and_its_timer_under_the_length_index(self, test_case: TestCase) -> None: + registers = _registers( + (test_case.control, SOUNDING_PULSE), + (test_case.timer_low, TIMER_LOW), + (test_case.timer_high, TIMER_HIGH), + ) + + assert channel_sound(test_case.channel, registers) == ChannelSound( + audible=True, + period=TIMER, + volume=LEVEL, + held=True, + timbre=DUTY, + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label) + def test_a_pulse_at_level_zero_is_silent(self, test_case: TestCase) -> None: + registers = _registers((test_case.control, SUSTAINED_LEVEL)) + + assert not channel_sound(test_case.channel, registers).audible + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label) + def test_a_pulse_running_its_envelope_sounds_counted_down_from_full(self, test_case: TestCase) -> None: + registers = _registers((test_case.control, LENGTH_COUNTER_HALT)) + + sound = channel_sound(test_case.channel, registers) + + assert sound.audible + assert not sound.held + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label) + def test_a_constant_level_under_a_running_length_counter_is_counted_down(self, test_case: TestCase) -> None: + registers = _registers((test_case.control, CONSTANT_VOLUME | LEVEL)) + + sound = channel_sound(test_case.channel, registers) + + assert (sound.audible, sound.volume, sound.held) == (True, LEVEL, False) + + +class TestTriangleSound: + def test_the_triangle_sounds_its_timer_while_its_linear_counter_reloads(self) -> None: + registers = _registers( + (TRIANGLE_LINEAR_COUNTER, SOUNDING_TRIANGLE), + (TRIANGLE_TIMER_LOW, TIMER_LOW), + (TRIANGLE_TIMER_HIGH, TIMER_HIGH), + ) + + assert channel_sound(ChannelName.TRIANGLE, registers) == ChannelSound( + audible=True, + period=TIMER, + volume=ABSENT_REGISTER, + held=True, + timbre=ABSENT_REGISTER, + ) + + @pytest.mark.parametrize("control", (TRIANGLE_COUNTER_CONTROL, 0), ids=("held", "released")) + def test_a_reload_of_zero_is_silent(self, control: int) -> None: + registers = _registers((TRIANGLE_LINEAR_COUNTER, control)) + + assert not channel_sound(ChannelName.TRIANGLE, registers).audible + + def test_a_clear_control_flag_counts_the_triangle_down(self) -> None: + registers = _registers((TRIANGLE_LINEAR_COUNTER, LINEAR_COUNTER_RELOAD)) + + sound = channel_sound(ChannelName.TRIANGLE, registers) + + assert sound.audible + assert not sound.held + + +class TestNoiseSound: + def test_the_noise_reads_its_level_its_period_index_and_its_mode(self) -> None: + registers = _registers((NOISE_CONTROL, SOUNDING_NOISE), (NOISE_PERIOD, SHORT_NOISE_PERIOD)) + + assert channel_sound(ChannelName.NOISE, registers) == ChannelSound( + audible=True, + period=NOISE_REGISTER_PERIOD, + volume=LEVEL, + held=True, + timbre=1, + ) + + def test_the_noise_at_level_zero_is_silent(self) -> None: + registers = _registers((NOISE_CONTROL, SUSTAINED_LEVEL), (NOISE_PERIOD, SHORT_NOISE_PERIOD)) + + assert not channel_sound(ChannelName.NOISE, registers).audible + + +class TestStatusRegister: + @pytest.mark.parametrize("channel", ChannelName.items(), ids=str) + def test_a_channel_the_status_register_leaves_disabled_is_silent(self, channel: ChannelName) -> None: + registers = _every_channel_sounding(CHANNELS_ENABLED & ~CHANNEL_STATUS_BITS[channel]) + + assert {other: channel_sound(other, registers).audible for other in ChannelName.items()} == { + other: other != channel for other in ChannelName.items() + } + + def test_every_channel_is_silent_before_any_write(self) -> None: + assert not any(channel_sound(channel, ChipRegisters.power_up()).audible for channel in ChannelName.items()) + + +class TestSongTrace: + def test_every_channel_is_read_on_every_tick(self) -> None: + positions = (TickPosition(frame=0, row=0), TickPosition(frame=0, row=1)) + registers = (_every_channel_sounding(CHANNELS_ENABLED), ChipRegisters.power_up()) + + trace = song_trace(positions, registers) + + assert trace.positions == positions + assert {channel: [sound.audible for sound in sounds] for channel, sounds in trace.channels.items()} == { + channel: [True, False] for channel in ChannelName.items() + } + + def test_registers_for_another_number_of_ticks_are_refused(self) -> None: + with pytest.raises(ValueError, match="2 ticks have 1"): + song_trace((TickPosition(frame=0, row=0),) * 2, (ChipRegisters.power_up(),)) diff --git a/tests/unit/sampletones_tools/tracker_playback/trace/test_registers.py b/tests/unit/sampletones_tools/tracker_playback/trace/test_registers.py new file mode 100644 index 000000000..cde29362d --- /dev/null +++ b/tests/unit/sampletones_tools/tracker_playback/trace/test_registers.py @@ -0,0 +1,31 @@ +from typing import Final + +from sampletones_player.specification.registers import APU_STATUS, PULSE1_CONTROL +from sampletones_tools.player.trace.write import RegisterWrite +from sampletones_tools.tracker_playback.trace.registers import POWER_UP_VALUE, ChipRegisters + +FIRST_VALUE: Final[int] = 0x3F +SECOND_VALUE: Final[int] = 0x30 + + +class TestChipRegisters: + def test_a_register_never_written_stands_at_the_power_up_value(self) -> None: + assert ChipRegisters.power_up().value(APU_STATUS) == POWER_UP_VALUE + + def test_a_register_keeps_the_last_value_written_to_it(self) -> None: + registers = ChipRegisters.power_up().written( + ( + RegisterWrite(PULSE1_CONTROL, FIRST_VALUE), + RegisterWrite(PULSE1_CONTROL, SECOND_VALUE), + ) + ) + + assert registers.value(PULSE1_CONTROL) == SECOND_VALUE + + def test_writes_lay_over_what_earlier_writes_left(self) -> None: + earlier = ChipRegisters.power_up().written((RegisterWrite(PULSE1_CONTROL, FIRST_VALUE),)) + + later = earlier.written((RegisterWrite(APU_STATUS, SECOND_VALUE),)) + + assert (later.value(PULSE1_CONTROL), later.value(APU_STATUS)) == (FIRST_VALUE, SECOND_VALUE) + assert earlier.value(APU_STATUS) == POWER_UP_VALUE