diff --git a/CHANGELOG.md b/CHANGELOG.md index 89b4fcdac..9affab8f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,9 +3,7 @@ ## v0.3.2 * Added NSF player and export. -* Added stems conversion: mix several recordings into one reconstruction. -* Matched each stem against its own recording, so a stem plays what was recorded on it. -* Added a per-source channel cap. +* Added stems conversion: to mix several recordings into one reconstruction. * Bumped the reconstruction data-version to `2.2` with backward compatibility for `2.1`. ## v0.3.1 [2026-08-18] diff --git a/docs/concepts/compression.md b/docs/concepts/compression.md index 95889a817..ed324fbb3 100644 --- a/docs/concepts/compression.md +++ b/docs/concepts/compression.md @@ -262,6 +262,12 @@ volume and duty turn over together and a split pays two opcodes for what one cov and the pitch index earns its place twice, 5 % directly and a further 12 % through the transposition it makes possible. +An export chooses how far down these layers it goes. The **Level** it is written at names the +layers read in order: *None* spells every plane out as literals, *Held sounds* adds holds, +*Samples* adds the phrases the project's samples seed, played transposed, and *Full search* is +the whole codec. A lighter level finishes sooner and takes more room, and every one of them +plays the same song. + ## 7. Limitations - **The search reads a dense plane only so far.** A reconstruction whose planes turn over diff --git a/docs/development/architecture.md b/docs/development/architecture.md index 2bbfd250a..b8910c7e3 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -190,7 +190,7 @@ They read the source as an AST through the source layer in `sampletones_tools/ch **Naming convention:** `ViewModel`, e.g. `ConverterViewModel`, `SequencerTrackerViewModel`. -**May import:** `constants/`, `sampletones_core` types, `sampletones_shared`, Python standard library. +**May import:** `constants/`, `sampletones_core` and `sampletones_player` types, `sampletones_shared`, Python standard library. **Must not import:** `ui/`, `coordinators/`, `logic/`, `services/`, `config/`. --- @@ -218,7 +218,7 @@ They read the source as an AST through the source layer in `sampletones_tools/ch - Session objects are simple state machines; they fire `on_state_changed` when they transition, without knowing who listens. - A logic object that drives a service declares a logic-side `Protocol` of exactly the calls it needs (e.g. `ConversionServiceProtocol`) and receives the real service from its coordinator or the composition root; structural typing keeps the dependency inverted. -**May import:** `sampletones_core`, `sampletones_shared`, `view_model/`, `utils/`, `categories/`, `layout/`, `config/`, and the service **result contract modules** (`services/result.py`, `services/*/result.py`) so handlers can type the tagged unions they match on. +**May import:** `sampletones_core`, `sampletones_player`, `sampletones_shared`, `view_model/`, `utils/`, `categories/`, `layout/`, `config/`, and the service **result contract modules** (`services/result.py`, `services/*/result.py`) so handlers can type the tagged unions they match on. **Must not import:** `ui/`, `coordinators/`, service implementation modules. --- @@ -257,6 +257,7 @@ There are two coordinator kinds: - 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, callbacks. - Callbacks received from `Application` as constructor parameters are stored and forwarded as-is. A wrapper is sanctioned where a contract requires an intent-level guard — a busy-authority start-time guard (principle 10) wrapping an operation's entry point — and that guard is the whole of what the wrapper holds. 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. - Error dialogs, confirmations, and notices are presented here, with text resolved from `LanguageManager` here (see the Error Handling Policy). +- An export format with choices of its own opens its setup where the save dialog would otherwise ask for the file. The composition root builds one mapping from each such format to its `ExportSetup` (`coordinators/export/setup.py`), and every surface offering an export consults it first, so a surface names no format and a format gains a setup in one place. **May import:** `ui/`, `view_model/`, `logic/`, `services/`, `utils/`, `categories/`, `layout/`, `config/`. **Must not import:** `application.py`, `shell.py`. diff --git a/docs/development/guidelines.md b/docs/development/guidelines.md index 811a92124..585d04398 100644 --- a/docs/development/guidelines.md +++ b/docs/development/guidelines.md @@ -84,6 +84,7 @@ These rules govern the Python in this repository. They complement 1. Reach for a negative example only when the contrast teaches something the positive statement cannot, and use it sparingly. One well-placed "what to avoid" illuminates; a document written mostly in negatives is noise. 1. State each fact once, in the document that owns it, and cross-reference sibling documents rather than repeating them. 1. A document change is part of the change that motivates it. Code that alters a contract a document states lands together with the edit stating the new contract, and a deviation the change knowingly leaves behind lands with an entry in the ledger that document names. What a branch leaves behind is therefore the current contract, the recorded distance from it, or both. +1. Changelog is only for changes that are meaningful to users. In particular, refactors that introduce no new features must not be present in the changelog. 1. Use American English, in prose and identifiers alike. A name someone else owns keeps the spelling they gave it: `MatchRule.serialise()` is jeepney's, `CancelledError` is the standard library's. ## Guide diff --git a/docs/development/packages.md b/docs/development/packages.md index 41f974222..3912a932e 100644 --- a/docs/development/packages.md +++ b/docs/development/packages.md @@ -101,7 +101,7 @@ them. | `builder.py` | The song a reconstruction or an export request plays as, its instructions encoded, its planes compressed and its rate scheduled | `song.py`, `registers/`, `clock/`, `compression/` | | `nsf/` | The song block, the header and the `.nsf` file the console loads | `song.py`, `specification/`, `compression/`, `driver/` | | `driver/` | The assembled 6502 driver and the addresses its build reports | `specification/` | -| `export.py` | `NSFBackend` — the export seam answered in `.nsf` files, holding the driver every one of them carries and saying which stage a run is in | `builder.py`, `nsf/`, `driver/`, `compression/` | +| `export/` | `NSFBackend` — the export seam answered in `.nsf` files, writing each request as the program its source states or the one a user chose (`NSFProgram`: channels, repeat, compression scheme and header text), holding the driver every file carries and saying which stage a run is in | `builder.py`, `song.py`, `nsf/`, `driver/`, `compression/` | ### The toolchain and the oracle live with the tools diff --git a/docs/development/player.md b/docs/development/player.md index 65ff6b590..8925c363e 100644 --- a/docs/development/player.md +++ b/docs/development/player.md @@ -65,6 +65,14 @@ from: `song_from_reconstruction` sounds a reconstruction's own instructions, whole arrangement out row by row through the same walk the sequencer sounds a song with. The last of those is the one that seeds the dictionary from the project's samples. +**A program states what an export chooses.** `NSFProgram` holds the channels a song sounds, +the tick it returns to, the `CompressionScheme` it is written with and the header text, and +the builders take the first three explicitly. `NSFProgram.for_project` and +`NSFProgram.for_sample` state what an export writes when nobody chose otherwise, and the +export dialog opens on the same values. `NSFBackend` answers the export seam either with those +stated programs or, through `choosing`, with one a user settled, so the service that runs an +export stays free of anything the format decides. + A project carries no tuning of its own — each sample was reconstructed against one — so the samples state it by agreeing on it, and a project whose samples disagree is refused rather than sounded half in tune. diff --git a/docs/formats/nsf.md b/docs/formats/nsf.md index de3838c94..b161348a9 100644 --- a/docs/formats/nsf.md +++ b/docs/formats/nsf.md @@ -29,7 +29,9 @@ change made in one file and forgotten in the other is reported by name. The header is NSF version 1: the magic `NESM\x1a`, one song, the load, init and play addresses, three 32-byte text fields, and the NTSC play period. The text fields carry the -name of what was exported, its author and the copyright. Written by `nsf/header.py`. +title, the artist and the copyright an export states. Each is UTF-8 ending in a NUL, so it +holds `STRING_TEXT_SIZE` bytes of text, cut on a character boundary; `nsf/information.py` +states that cut, and `nsf/header.py` writes the header. The driver's entry points lead its image as a pair of jumps, so `init` answers at the load address and `play` three bytes later whatever the driver's own length. That is what lets @@ -140,6 +142,10 @@ begins a token on every plane, and that token names its values outright rather t leaning on the value the plane had reached. Coming round is then a matter of pointing each plane at the byte the header states and clearing what it was playing. +The tick a song returns to is the export's choice: its first tick, the first tick of an +order frame, or none at all, in which case the header states `$FFFF` and the song stops at +its end. + ## C. What the planes hold The planes are written in this order, and each group belongs to one channel: @@ -161,6 +167,10 @@ The planes are written in this order, and each group belongs to one channel: Splitting a channel's registers apart is what gives each plane something to repeat: a volume envelope and a pitch line are separate series that turn over at their own rates. +**Every channel's planes are in every block.** A channel an export leaves out holds its +silent values from the first tick to the last, which a hold covers in a few bytes, so the +driver reads the same layout whichever channels a song sounds. + **The noise channel reads no bend.** It selects one of sixteen fixed periods, so there is no finer grid for a bend to reach, and the plane it would hold is left out of the block. diff --git a/docs/guide/files.md b/docs/guide/files.md index ec0483e27..f4bc690c7 100644 --- a/docs/guide/files.md +++ b/docs/guide/files.md @@ -46,7 +46,7 @@ The name you type also names the instrument in the tracker: | Export | The name you type | What the app saves | | --- | --- | --- | | **Instruments** panel ▸ **Export instrument...** | the file | one file, and the instrument inside it has the same name | -| **Reconstruction ▸ Export instruments** | the set | one file per channel, each named ` (channel)`. For `.nsf`, one file with the name you typed | +| **Reconstruction ▸ Export instruments** | the set | one file per channel, each named ` (channel)`. For `.nsf`, one file, and its title is set in the **Export NSF program** window | | **File ▸ Export** | the file | one file with the whole song | For example, exporting a reconstruction named `Kick` to FamiTracker instruments saves diff --git a/docs/guide/reconstruction.md b/docs/guide/reconstruction.md index 1ded88432..fd7b96944 100644 --- a/docs/guide/reconstruction.md +++ b/docs/guide/reconstruction.md @@ -54,7 +54,7 @@ You export a reconstruction from the **Reconstruction** menu: - **Export instruments ▸ FamiTracker instruments...** writes one `.fti` file per channel. - **Export instruments ▸ Bitphase presets...** writes the same instruments as `.json`. -- **Export instruments ▸ NSF program...** writes a single `.nsf` file that plays the whole reconstruction on a NES. +- **Export instruments ▸ NSF program...** opens the **Export NSF program** window and writes a single `.nsf` file that plays the whole reconstruction on a NES. The window works as it does [in the sequencer](sequencer.md#exporting-an-nsf-program). A reconstruction has no order frames, so **Repeat** has no **From a frame**. It has no samples either, so **Level** has no **Samples**. You can only select the channels the reconstruction uses. - **Export to WAV...** renders the audio using the channel and stem checkboxes you have set. **Export instrument...** in the **Instruments** panel writes only the channel you are looking at, in whichever format you choose in the save dialog. See [where your files live](files.md#naming-exported-files). diff --git a/docs/guide/sequencer.md b/docs/guide/sequencer.md index fc6984124..520bb775b 100644 --- a/docs/guide/sequencer.md +++ b/docs/guide/sequencer.md @@ -260,6 +260,21 @@ To export the song: directly. An NSF program has room for 32 KB, so the app tells you when a song is too long. See [NSF export](../formats/nsf.md) and [song compression](../concepts/compression.md). +## Exporting an NSF program + +**File ▸ Export ▸ NSF program...** opens the **Export NSF program** window. Click **Export** without +changing anything to save the whole song, repeating from the start. + +| Setting | What it does | +|---------|--------------| +| **Title**, **Artist**, **Copyright** | The text an NSF player shows. Each one holds 31 bytes | +| **Channels** | The channels the program plays. A channel you clear stays silent. Select at least one | +| **Repeat** | **Play once** stops at the end. **From the start** plays the song again. **From a frame** goes back to the order frame you type in **Frame**, numbered as in the order list | +| **Level** | How much the song is compressed. **None** exports fastest and makes the largest file. **Full search** is the slowest and makes the smallest file | +| **File** | Where the file is saved. **Browse...** opens the save dialog | + +**Export** closes the window and shows the export's progress. + ## Rendering to audio **File ▸ Render song...** (`Ctrl+Shift+E`) saves the whole song as an audio file that any player diff --git a/src/sampletones/self_check.py b/src/sampletones/self_check.py index 9841e8f94..613c6ef53 100644 --- a/src/sampletones/self_check.py +++ b/src/sampletones/self_check.py @@ -135,9 +135,9 @@ def _check_export_backends() -> str: A backend reads the resources it writes with as it is built, so this is where a build shipping without one — the player's assembled driver among them — names what is missing. """ - from sampletones_application.exports import build_export_backends + from sampletones_application.exports import ExportBackends - return ", ".join(sorted(build_export_backends())) + return ", ".join(sorted(ExportBackends.build().by_format)) def _check_file_dialog_backend() -> str: diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index c2a924bd6..cd22fe249 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -20,6 +20,8 @@ InstrumentExportCoordinator, SongExportCoordinator, ) +from sampletones_application.coordinators.export.nsf import NSFExportCoordinator +from sampletones_application.coordinators.export.setup import ExportSetup from sampletones_application.coordinators.keybindings import KeybindingsCoordinator from sampletones_application.coordinators.original_audio import OriginalAudioLocator from sampletones_application.coordinators.playback.protocol import AudioPlayerProtocol @@ -38,10 +40,11 @@ ReconstructionTabCoordinator, ) from sampletones_application.coordinators.tabs.sequencer.coordinator import SequencerTabCoordinator -from sampletones_application.exports import build_export_backends +from sampletones_application.exports import ExportBackends from sampletones_application.layout import LayoutConfig, load_layout_config from sampletones_application.logic.export import SongExportLogic from sampletones_application.logic.export.instrument.logic import InstrumentExportLogic +from sampletones_application.logic.export.nsf.logic import NSFExportLogic from sampletones_application.logic.history.action import HistoryAction from sampletones_application.logic.history.manager import HistoryManager from sampletones_application.logic.instruction.library_manager import ( @@ -117,6 +120,7 @@ ) from sampletones_application.ui.panels.dialogs.export import GUIExportWindow from sampletones_application.ui.panels.dialogs.keybindings import GUIKeybindingsWindow +from sampletones_application.ui.panels.dialogs.nsf import GUINSFExportWindow from sampletones_application.ui.panels.dialogs.project_properties import ( GUIProjectPropertiesWindow, ) @@ -172,6 +176,7 @@ 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 ( SAMPLETONES_AUTHOR, SAMPLETONES_GROUP, @@ -263,7 +268,9 @@ def __init__( self.retune_service: SampleRetuneService = SampleRetuneService(priority=_priority) self.retune_service.subscribe(self._on_retune_result) - self.export_backends: Dict[ExportFormat, ExportBackend] = build_export_backends() + backends = ExportBackends.build() + self.nsf_backend: NSFBackend = backends.nsf + self.export_backends: Dict[ExportFormat, ExportBackend] = backends.by_format self.project_manager: ProjectManager = ProjectManager() self.project_controller: ProjectController = ProjectController(self.project_manager) @@ -340,6 +347,15 @@ def __init__( key_router=self.key_router, shortcut_source=self._shortcut_source, ) + self.nsf_export_window: GUINSFExportWindow = GUINSFExportWindow( + layout=self.layout.settings, + path_colors=self.layout.general.colors.paths, + text_colors=self.layout.general.colors.text, + language_manager=self.language_manager, + key_router=self.key_router, + shortcut_source=self._shortcut_source, + status_bar=self.status_bar, + ) self.project_properties_window: GUIProjectPropertiesWindow = GUIProjectPropertiesWindow( layout=self.layout.project_properties, language_manager=self.language_manager, @@ -398,12 +414,29 @@ def __init__( language_manager=self.language_manager, ) + self._nsf_exports = NSFExportCoordinator( + NSFExportLogic( + self.project_controller, + self.session_manager, + self.export_service, + self.nsf_backend, + is_operation_active=self._is_operation_active, + ), + window=self.nsf_export_window, + language_manager=self.language_manager, + on_activity_changed=self._on_dialog_activity_changed, + ) + self._format_setups: Dict[ExportFormat, ExportSetup] = { + ExportFormat.NSF: self._nsf_exports, + } + self._project_coordinator = ProjectCoordinator( self.project_controller, self.project_manager, self.session_manager, self.export_service, export_backends=self.export_backends, + format_setups=self._format_setups, dialogs=self.dialogs, language_manager=self.language_manager, on_tab_switch=self._set_current_tab, @@ -447,6 +480,7 @@ def __init__( browser_manager=self.browser_manager, 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_change_audio_state=self._update_menu, on_favorite_changed=self._repaint_reconstruction_favorites, @@ -568,7 +602,7 @@ def __init__( window=self.render_window, dialogs=self.dialogs, language_manager=self.language_manager, - on_activity_changed=self._on_render_activity_changed, + on_activity_changed=self._on_dialog_activity_changed, ) self._export_logic = SongExportLogic( @@ -975,6 +1009,7 @@ def _is_operation_active(self) -> bool: self._main_tab.is_converter_active() or self._instructions_tab.is_library_generating() or self._render_coordinator.is_active + or self._nsf_exports.is_active or self.export_service.is_running() ) @@ -1001,12 +1036,12 @@ def _refresh_busy_state(self) -> None: self._instructions_tab.refresh_generate_button() self._update_menu() - def _on_render_activity_changed(self) -> None: - """Follows a render claiming the application and handing it back. + def _on_dialog_activity_changed(self) -> None: + """Follows a render or an NSF export setup claiming the application and handing it back. - What a render occupies is the same ground a conversion or a library generation occupies, - so its edges reach the same busy state — the action buttons of each tab, the converter's - own view, and the menu entries that would start another exclusive operation. + What either dialog occupies is the same ground a conversion or a library generation + occupies, so its edges reach the same busy state — the action buttons of each tab, the + converter's own view, and the menu entries that would start another exclusive operation. """ self._refresh_busy_state() self._main_tab.refresh_converter_view() diff --git a/src/sampletones_application/categories/hierarchy.py b/src/sampletones_application/categories/hierarchy.py index 20d4643c8..c539cf7e9 100644 --- a/src/sampletones_application/categories/hierarchy.py +++ b/src/sampletones_application/categories/hierarchy.py @@ -100,3 +100,4 @@ class Panel(StrEnum): PROPERTIES = auto() RENDER = auto() EXPORT = auto() + NSF = auto() diff --git a/src/sampletones_application/coordinators/export/nsf.py b/src/sampletones_application/coordinators/export/nsf.py new file mode 100644 index 000000000..518f01b50 --- /dev/null +++ b/src/sampletones_application/coordinators/export/nsf.py @@ -0,0 +1,135 @@ +from pathlib import Path +from typing import Optional + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.logic.export.nsf.logic import NSFExportLogic +from sampletones_application.ui.panels.dialogs.nsf import GUINSFExportWindow +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 +from sampletones_shared.types.callback import VoidCallback + + +class NSFExportCoordinator: + """Owns setting an NSF export up from the reader's side: the dialog, the file it asks for, and + the hand-over to the run. + + This is the NSF format's :class:`ExportSetup`, so File > Export and the Reconstructions tab + open it where the save dialog would otherwise ask for the file. The logic holds the setup, so + what is orchestrated here is the screen: the window opens over the choices the logic offers and + follows every view it emits, and the destination is asked for through the OS dialog. + + The setup claims the application from the dialog opening. Every way out passes through the + logic's close and tells the application to read the claim again, and Export closes the dialog + a frame ahead of the run, so the export's own window opens onto a clear screen. + """ + + def __init__( + self, + nsf_logic: NSFExportLogic, + *, + window: GUINSFExportWindow, + language_manager: LanguageManager, + on_activity_changed: VoidCallback, + ) -> None: + self._logic = nsf_logic + self._window = window + self._on_activity_changed = on_activity_changed + self._view_model: Optional[NSFExportViewModel] = None + self._window_open = False + + self._title_destination = language_manager["global.dialog.title.export_nsf_project"] + self._filter_name = language_manager["global.dialog.filter.nsf"] + + self._logic.on_view_changed = self._on_view_changed + self._logic.on_choose_destination = self._choose_destination + + self._window.on_choices_changed = self._logic.apply + self._window.on_browse = self._logic.request_destination + self._window.on_export = self._export + self._window.on_close = self._close + + @property + def is_active(self) -> bool: + """An NSF export occupies the application from the dialog opening until the run takes over.""" + return self._logic.is_active + + def open_project(self) -> None: + """Offers the open project's song as a program.""" + self._open(self._logic.open_project()) + + def open_sample(self, request: SampleExport) -> None: + """Offers a reconstruction's slices as a program.""" + self._open(self._logic.open_sample(request)) + + def _open(self, opened: bool) -> None: + if not opened: + return + + self._window_open = True + self._window.open(self._require_view_model()) + self._on_activity_changed() + + def _on_view_changed(self, view_model: NSFExportViewModel) -> None: + """Keeps the open window standing at the choices the export stands at.""" + self._view_model = view_model + if self._window_open: + self._window.update_view(view_model) + + def _choose_destination(self, destination: Path) -> None: + """Asks for the file the export writes, starting from the one the dialog stands at.""" + filepath = save_file_dialog( + title=self._title_destination, + initial_directory=destination.parent, + default_filename=destination.name, + filters=(FileFilter.for_extensions(self._filter_name, [EXT_FILE_NSF]),), + ) + + self._set_destination(filepath) + + @ignore_none_path + 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. + """ + self._take_off_screen() + FrameCallbackManager.set_frame_callback(self._hand_over) + + def _hand_over(self) -> None: + self._logic.start() + self._release() + + def _close(self) -> None: + """Takes the dialog off screen and hands the application back.""" + self._take_off_screen() + self._release() + + def _take_off_screen(self) -> None: + self._window_open = False + self._view_model = None + self._window.hide() + + def _release(self) -> None: + self._logic.close() + self._on_activity_changed() + + def _require_view_model(self) -> NSFExportViewModel: + """The export the dialog opens on. + + Raises: + SystemError: when the window is raised before the logic offers a view. + """ + if self._view_model is None: + raise SystemError("The NSF export window is opened over the view the logic emits") + + return self._view_model diff --git a/src/sampletones_application/coordinators/export/setup.py b/src/sampletones_application/coordinators/export/setup.py new file mode 100644 index 000000000..28cbd5512 --- /dev/null +++ b/src/sampletones_application/coordinators/export/setup.py @@ -0,0 +1,16 @@ +from typing import Protocol + +from sampletones_core.exports.request import SampleExport + + +class ExportSetup(Protocol): + """A format's own setup dialog, opened where a save dialog would otherwise ask for the file. + + A format with choices of its own asks for them together with the destination, so the + surfaces offering an export consult the formats' setups first and reach the save dialog for + the formats that ask for a file alone. + """ + + def open_project(self) -> None: ... + + def open_sample(self, request: SampleExport) -> None: ... diff --git a/src/sampletones_application/coordinators/project.py b/src/sampletones_application/coordinators/project.py index 4eafc8432..21f0c3f9f 100644 --- a/src/sampletones_application/coordinators/project.py +++ b/src/sampletones_application/coordinators/project.py @@ -1,6 +1,6 @@ from functools import partial from pathlib import Path -from typing import Dict, Optional, Tuple +from typing import Dict, Mapping, Optional, Tuple from sampletones_application.categories.elements.global_ import ( DialogElements, @@ -12,6 +12,7 @@ from sampletones_application.categories.hierarchy import Page, Panel, Tab, TextType from sampletones_application.categories.manager import LanguageManager from sampletones_application.config.managers.session import SessionManager +from sampletones_application.coordinators.export.setup import ExportSetup from sampletones_application.logic.project.controller import ProjectController from sampletones_application.logic.project.manager import ProjectManager from sampletones_application.services.export.error import ExportError @@ -70,6 +71,7 @@ def __init__( export_service: ExportService, *, export_backends: Dict[ExportFormat, ExportBackend], + format_setups: Mapping[ExportFormat, ExportSetup], dialogs: DialogsRenderer, language_manager: LanguageManager, on_tab_switch: Callback, @@ -80,6 +82,7 @@ def __init__( self._session_manager = session_manager self._export_service = export_service self._export_backends = export_backends + self._format_setups = format_setups self._dialogs = dialogs self._language_manager = language_manager self._on_tab_switch = on_tab_switch @@ -198,7 +201,11 @@ def _get_project_filename(self, extension: str) -> str: return get_filename(name, extension) def export_project_dialog(self, export_format: ExportFormat) -> None: - """Prompts for a destination and writes the open project in ``export_format``. + """Writes the open project in ``export_format``, asking first for what the format leaves open. + + A format with a setup of its own opens it, and that setup asks for the destination with + the rest of its choices. Every other format asks for the destination through the save + dialog. Args: export_format: The format the project is written in. @@ -206,6 +213,10 @@ def export_project_dialog(self, export_format: ExportFormat) -> None: if not self._project_controller.is_open: return + if export_format in self._format_setups: + self._format_setups[export_format].open_project() + return + backend = self._export_backends[export_format] elements = EXPORT_PROJECT_ELEMENTS[export_format] extension = backend.extension(ExportScope.PROJECT) diff --git a/src/sampletones_application/coordinators/tabs/reconstruction.py b/src/sampletones_application/coordinators/tabs/reconstruction.py index be6079847..7050470f1 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, Optional, Sequence +from typing import Callable, Dict, Mapping, Optional, Sequence import dearpygui.dearpygui as dpg @@ -13,6 +13,7 @@ from sampletones_application.coordinators.export.instrument import ( InstrumentExportCoordinator, ) +from sampletones_application.coordinators.export.setup import ExportSetup from sampletones_application.coordinators.original_audio import OriginalAudioLocator from sampletones_application.coordinators.playback.guard import GuardedPlayer from sampletones_application.coordinators.playback.protocol import AudioPlayerProtocol @@ -132,6 +133,7 @@ def __init__( browser_manager: BrowserManager, export_service: ExportService, export_backends: Dict[ExportFormat, ExportBackend], + format_setups: Mapping[ExportFormat, ExportSetup], on_load_reconstruction_with_confirmation: Callable[[Optional[Path]], None], on_change_audio_state: VoidCallback, on_favorite_changed: Callable[[FileSystemNode], None], @@ -159,6 +161,7 @@ def __init__( ) self._session_manager = session_manager self._export_backends = export_backends + self._format_setups = format_setups self._instrument_exports = instrument_exports self._dialogs = dialogs self._original_audio_locator = original_audio_locator @@ -761,6 +764,12 @@ def request_export_instruments_dialog( self, export_format: ExportFormat, ) -> None: + """Writes the loaded reconstruction's slices in ``export_format``, asking first for what the + format leaves open: its own setup where it has one, and the destination alone otherwise.""" + if export_format in self._format_setups: + self._format_setups[export_format].open_sample(self._reconstruction_panel_logic.sample_request()) + return + self._reconstruction_panel_logic.request_export_instruments_dialog(export_format) def _on_preview_error(self, exception: Exception) -> None: diff --git a/src/sampletones_application/exports.py b/src/sampletones_application/exports.py index f36e433d9..6a79c7951 100644 --- a/src/sampletones_application/exports.py +++ b/src/sampletones_application/exports.py @@ -1,23 +1,49 @@ +from __future__ import annotations + +from dataclasses import dataclass from typing import Dict from sampletones_core.exports.backend import ExportBackend from sampletones_core.exports.format import ExportFormat from sampletones_core.exports.registry import build_tracker_backends -from sampletones_player.export import NSFBackend +from sampletones_player.export.backend import NSFBackend -def build_export_backends() -> Dict[ExportFormat, ExportBackend]: - """Builds every backend the application exports through. +@dataclass(frozen=True) +class ExportBackends: + """Every backend the application exports through. The reconstruction engine stands below the console player and owns the tracker formats alone, so the backend writing a program the console runs joins them here, where both packages are in reach. Everything the application offers to export in is keyed by its - format in the result, so a menu entry, a file type and a shortcut all reach one backend. + format in :attr:`by_format`, so a menu entry, a file type and a shortcut all reach one + backend, and the console player's backend is named as well, since the NSF export setup binds + that same backend to the program it chooses. - Returns: - Dict[ExportFormat, ExportBackend]: Every backend, keyed by the format it writes. + Attributes: + nsf: The backend writing console programs. + trackers: The backends writing the tracker formats, keyed by the format each writes. """ - return { - **build_tracker_backends(), - ExportFormat.NSF: NSFBackend(), - } + + nsf: NSFBackend + trackers: Dict[ExportFormat, ExportBackend] + + @classmethod + def build(cls) -> ExportBackends: + """Builds every backend, reading the resources each writes with. + + Raises: + OSError: If the console player's packaged driver is absent. + """ + return cls( + nsf=NSFBackend.stated(), + trackers=build_tracker_backends(), + ) + + @property + def by_format(self) -> Dict[ExportFormat, ExportBackend]: + """Every backend, keyed by the format it writes.""" + return { + **self.trackers, + ExportFormat.NSF: self.nsf, + } diff --git a/src/sampletones_application/layout/settings/__init__.py b/src/sampletones_application/layout/settings/__init__.py index c23a3763b..8e4aae008 100644 --- a/src/sampletones_application/layout/settings/__init__.py +++ b/src/sampletones_application/layout/settings/__init__.py @@ -4,6 +4,7 @@ from sampletones_application.layout.settings.display import DisplaySettingsLayout from sampletones_application.layout.settings.export.export import ExportSettingsLayout from sampletones_application.layout.settings.keybindings import KeybindingsSettingsLayout +from sampletones_application.layout.settings.nsf import NSFSettingsLayout from sampletones_application.layout.settings.render import RenderSettingsLayout @@ -21,3 +22,4 @@ class SettingsLayout(BaseModel, extra="forbid", frozen=True): keybindings: KeybindingsSettingsLayout render: RenderSettingsLayout export: ExportSettingsLayout + nsf: NSFSettingsLayout diff --git a/src/sampletones_application/layout/settings/nsf.py b/src/sampletones_application/layout/settings/nsf.py new file mode 100644 index 000000000..a3eb7dac9 --- /dev/null +++ b/src/sampletones_application/layout/settings/nsf.py @@ -0,0 +1,18 @@ +from pydantic import BaseModel + +from sampletones_application.layout.primitives import Dimensions + + +class NSFSettingsLayout(BaseModel, extra="forbid", frozen=True): + """The NSF export dialog's geometry. + + Attributes: + window: The dialog's size, its height a floor the content grows past. + text_width: The width of a header text field, negative to leave its byte count room at the + row's end. + frame_width: The width of the field a repeat's order frame is typed into. + """ + + window: Dimensions + text_width: int + frame_width: int diff --git a/src/sampletones_application/logic/export/nsf/__init__.py b/src/sampletones_application/logic/export/nsf/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_application/logic/export/nsf/draft.py b/src/sampletones_application/logic/export/nsf/draft.py new file mode 100644 index 000000000..15924217e --- /dev/null +++ b/src/sampletones_application/logic/export/nsf/draft.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from dataclasses import dataclass, replace +from pathlib import Path +from typing import Optional + +from sampletones_application.logic.export.nsf.source.protocol import NSFExportSource +from sampletones_application.view_model.shared.nsf.choices import NSFExportChoices +from sampletones_application.view_model.shared.nsf.offer import NSFExportOffer +from sampletones_application.view_model.shared.nsf.repeat import NSFRepeat +from sampletones_application.view_model.shared.nsf.view import NSFExportViewModel +from sampletones_player.builder import SONG_START +from sampletones_player.export.program import NSFProgram + + +@dataclass(frozen=True) +class NSFExportDraft: + """An NSF export as it stands while its dialog is open. + + Attributes: + source: What the export writes. + offer: What the source leaves to choose, read once the dialog opens. + choices: The choices the dialog stands at. + destination: The file the export writes. + """ + + source: NSFExportSource + offer: NSFExportOffer + choices: NSFExportChoices + destination: Path + + def with_choices(self, choices: NSFExportChoices) -> NSFExportDraft: + return replace(self, choices=choices) + + def with_destination(self, destination: Path) -> NSFExportDraft: + return replace(self, destination=destination) + + def program(self) -> NSFProgram: + """The program the standing choices write, its repeat placed on the source's own ticks. + + Raises: + ValidationError: If no channel is ticked. + """ + return NSFProgram( + information=self.choices.information, + channels=self.choices.channels, + loop_tick=self._loop_tick(), + scheme=self.choices.scheme, + ) + + def _loop_tick(self) -> Optional[int]: + """The tick the song returns to: the first one, the one the chosen frame starts on, or none.""" + match self.choices.repeat: + case NSFRepeat.ONCE: + return None + case NSFRepeat.FROM_START: + return SONG_START + case NSFRepeat.FROM_FRAME: + return self.source.frame_tick(self.choices.loop_frame) + + def view(self) -> NSFExportViewModel: + return NSFExportViewModel( + offer=self.offer, + choices=self.choices, + destination=self.destination, + ticks=self.source.ticks(self.choices.channels), + nes_frequency=self.source.nes_frequency, + ) diff --git a/src/sampletones_application/logic/export/nsf/logic.py b/src/sampletones_application/logic/export/nsf/logic.py new file mode 100644 index 000000000..4a55f9dd1 --- /dev/null +++ b/src/sampletones_application/logic/export/nsf/logic.py @@ -0,0 +1,147 @@ +from pathlib import Path +from typing import Callable, Optional + +from sampletones_application.config.managers.session import SessionManager +from sampletones_application.logic.export.nsf.draft import NSFExportDraft +from sampletones_application.logic.export.nsf.protocol import ( + NSFExportServiceProtocol, + NSFProgramBackend, +) +from sampletones_application.logic.export.nsf.source.project import ProjectSource +from sampletones_application.logic.export.nsf.source.protocol import NSFExportSource +from sampletones_application.logic.export.nsf.source.sample import SampleSource +from sampletones_application.logic.project.controller import ProjectController +from sampletones_application.view_model.shared.nsf.choices import NSFExportChoices +from sampletones_application.view_model.shared.nsf.view import NSFExportViewModel +from sampletones_core.exports.request import SampleExport +from sampletones_shared.constants.project import DEFAULT_EXPORT_NAME +from sampletones_shared.logger import logger +from sampletones_shared.types.callback import PathCallback +from sampletones_shared.utils.callbacks import CallbackMixin +from sampletones_shared.utils.system.paths import get_filename + + +class NSFExportLogic(CallbackMixin): + """Owns setting an NSF export up: what it writes, the program it writes it as, and the file. + + The dialog opens on the program the export writes as its source states it, so a reader + changing nothing writes what the save dialog alone would have written. Every choice is the + player's own, held in the form the file states it, and the source places the choices on its + own ticks once the export is handed over. + + Setting an export up is an exclusive operation, claimed the moment the dialog opens. Handing + the export over closes the setup in the same call the service starts running in, so the + application stays busy from the dialog opening until the run reports its outcome. + """ + + def __init__( + self, + project_controller: ProjectController, + session_manager: SessionManager, + export_service: NSFExportServiceProtocol, + backend: NSFProgramBackend, + *, + is_operation_active: Callable[[], bool], + ) -> None: + self._project_controller = project_controller + self._session_manager = session_manager + self._service = export_service + self._backend = backend + self._is_operation_active = is_operation_active + self._draft: Optional[NSFExportDraft] = None + + self.on_view_changed: Optional[Callable[[NSFExportViewModel], None]] = None + self.on_choose_destination: Optional[PathCallback] = None + + @property + def is_active(self) -> bool: + """An NSF export occupies the application while its setup is open.""" + return self._draft is not None + + def open_project(self) -> bool: + """Offers the open project's song as a program, reporting whether the setup took over.""" + return self._open( + ProjectSource( + request=self._project_controller.export_request, + name=self._project_controller.name or DEFAULT_EXPORT_NAME, + ) + ) + + def open_sample(self, request: SampleExport) -> bool: + """Offers a reconstruction's slices as a program, reporting whether the setup took over.""" + return self._open(SampleSource(request=request)) + + def apply(self, choices: NSFExportChoices) -> None: + """Takes the reconciled choices the dialog reports.""" + if self._draft is None: + return + + self._draft = self._draft.with_choices(choices) + self._emit_view() + + def request_destination(self) -> None: + """Asks for the file the export writes, starting from the one standing.""" + self.call(self.on_choose_destination, self._require_draft().destination) + + def set_destination(self, destination: Path) -> None: + """Writes the export to ``destination``.""" + self._draft = self._require_draft().with_destination(destination) + self._emit_view() + + def start(self) -> bool: + """Hands the export to the service under the standing choices, and closes the setup. + + Returns: + bool: Whether the export was handed over, which a ticked channel is what allows. + """ + draft = self._draft + if draft is None or not draft.choices.writable: + return False + + draft.source.remember_directory(self._session_manager, draft.destination.parent) + draft.source.submit( + self._service, + draft.destination, + self._backend.choosing(draft.program()), + ) + self.close() + return True + + def close(self) -> None: + """Leaves the setup, releasing the application.""" + self._draft = None + + def _open(self, source: NSFExportSource) -> bool: + """Sets an export of ``source`` up, where no other exclusive operation holds the application. + + The destination is proposed afresh each time, carrying the source's name into the folder + that kind of export opens on. + """ + if self._is_operation_active(): + logger.warning("An exclusive operation is already in progress; the NSF export was not offered") + return False + + offer = source.offer() + self._draft = NSFExportDraft( + source=source, + offer=offer, + choices=NSFExportChoices.initial(source.program(), offer), + destination=source.proposed_directory(self._session_manager) + / get_filename(source.name, self._backend.extension(source.scope)), + ) + self._emit_view() + return True + + def _require_draft(self) -> NSFExportDraft: + """The export the open dialog sets up. + + Raises: + SystemError: when the setup is driven while its dialog is closed. + """ + if self._draft is None: + raise SystemError("An NSF export is set up only while its dialog is open") + + return self._draft + + def _emit_view(self) -> None: + self.call(self.on_view_changed, self._require_draft().view()) diff --git a/src/sampletones_application/logic/export/nsf/protocol.py b/src/sampletones_application/logic/export/nsf/protocol.py new file mode 100644 index 000000000..53fab62f7 --- /dev/null +++ b/src/sampletones_application/logic/export/nsf/protocol.py @@ -0,0 +1,37 @@ +from pathlib import Path +from typing import Protocol + +from sampletones_core.exports.backend import ExportBackend +from sampletones_core.exports.request import ProjectExport, SampleExport +from sampletones_core.exports.scope import ExportScope +from sampletones_player.export.program import NSFProgram + + +class NSFExportServiceProtocol(Protocol): + """The slice of the export service an NSF export hands its run to. + + Typing the collaborator structurally keeps the logic layer independent of the service + implementation; the composition root supplies the real service. + """ + + def export_sample( + self, + destination: Path, + backend: ExportBackend, + request: SampleExport, + ) -> None: ... + + def export_project( + self, + destination: Path, + backend: ExportBackend, + request: ProjectExport, + ) -> None: ... + + +class NSFProgramBackend(Protocol): + """The backend writing console programs, which binds a run to the program a setup chose.""" + + def choosing(self, program: NSFProgram) -> ExportBackend: ... + + def extension(self, scope: ExportScope) -> str: ... diff --git a/src/sampletones_application/logic/export/nsf/source/__init__.py b/src/sampletones_application/logic/export/nsf/source/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_application/logic/export/nsf/source/project.py b/src/sampletones_application/logic/export/nsf/source/project.py new file mode 100644 index 000000000..8142a8ee2 --- /dev/null +++ b/src/sampletones_application/logic/export/nsf/source/project.py @@ -0,0 +1,75 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import AbstractSet + +from sampletones_application.config.managers.session import SessionManager +from sampletones_application.logic.export.nsf.protocol import NSFExportServiceProtocol +from sampletones_application.view_model.shared.nsf.offer import NSFExportOffer +from sampletones_core.constants.enums import ChannelName +from sampletones_core.exports.backend import ExportBackend +from sampletones_core.exports.request import ProjectExport +from sampletones_core.exports.scope import ExportScope +from sampletones_core.timing import SongTiming +from sampletones_player.compression.scheme import offered_schemes +from sampletones_player.export.program import NSFProgram +from sampletones_shared.utils.system.paths import get_directory + + +@dataclass(frozen=True) +class ProjectSource: + """The open project, written as a program playing its whole song. + + Attributes: + request: The project the export writes. + name: The name the project is known by, which the proposed file carries. + """ + + request: ProjectExport + name: str + + @property + def scope(self) -> ExportScope: + return ExportScope.PROJECT + + @property + def nes_frequency(self) -> int: + return self.request.project.settings.nes_frequency + + def offer(self) -> NSFExportOffer: + """Every channel, the frames of the order, and every scheme, the samples seeding the dictionary.""" + return NSFExportOffer( + channels=tuple(ChannelName.items()), + frame_count=self.request.project.song.order_length(), + schemes=offered_schemes(seeded=True), + ) + + def program(self) -> NSFProgram: + return NSFProgram.for_project(self.request.project) + + def ticks(self, channels: AbstractSet[ChannelName]) -> int: # pylint: disable=unused-argument + """The ticks the whole order plays for, which every channel shares.""" + project = self.request.project + return SongTiming.from_project(project).frame_tick(project.song.order_length()) + + def frame_tick(self, frame: int) -> int: + """The tick order frame ``frame`` starts on, under the groove the song plays at.""" + return SongTiming.from_project(self.request.project).frame_tick(frame) + + def proposed_directory(self, session_manager: SessionManager) -> Path: + """The folder the project was last opened or saved in.""" + return get_directory(session_manager.get_project_path()) + + def remember_directory( # pylint: disable=unused-argument + self, + session_manager: SessionManager, + directory: Path, + ) -> None: + """Keeps the project's folder where opening and saving the project set it.""" + + def submit( + self, + service: NSFExportServiceProtocol, + destination: Path, + backend: ExportBackend, + ) -> None: + service.export_project(destination, backend, self.request) diff --git a/src/sampletones_application/logic/export/nsf/source/protocol.py b/src/sampletones_application/logic/export/nsf/source/protocol.py new file mode 100644 index 000000000..13c5bbfb2 --- /dev/null +++ b/src/sampletones_application/logic/export/nsf/source/protocol.py @@ -0,0 +1,47 @@ +from pathlib import Path +from typing import AbstractSet, Protocol + +from sampletones_application.config.managers.session import SessionManager +from sampletones_application.logic.export.nsf.protocol import NSFExportServiceProtocol +from sampletones_application.view_model.shared.nsf.offer import NSFExportOffer +from sampletones_core.constants.enums import ChannelName +from sampletones_core.exports.backend import ExportBackend +from sampletones_core.exports.scope import ExportScope +from sampletones_player.export.program import NSFProgram + + +class NSFExportSource(Protocol): + """What an NSF export writes, answering every question the setup asks of it. + + A whole project and a reconstruction's slices reach a program through the same dialog, and + each states for itself what that dialog offers, what the export writes on its own, how long + the song runs, the tick each order frame starts on and which run of the service writes it. + """ + + @property + def scope(self) -> ExportScope: ... + + @property + def name(self) -> str: ... + + @property + def nes_frequency(self) -> int: ... + + def offer(self) -> NSFExportOffer: ... + + def program(self) -> NSFProgram: ... + + def ticks(self, channels: AbstractSet[ChannelName]) -> int: ... + + def frame_tick(self, frame: int) -> int: ... + + def proposed_directory(self, session_manager: SessionManager) -> Path: ... + + def remember_directory(self, session_manager: SessionManager, directory: Path) -> None: ... + + def submit( + self, + service: NSFExportServiceProtocol, + destination: Path, + backend: ExportBackend, + ) -> None: ... diff --git a/src/sampletones_application/logic/export/nsf/source/sample.py b/src/sampletones_application/logic/export/nsf/source/sample.py new file mode 100644 index 000000000..cd0d51a46 --- /dev/null +++ b/src/sampletones_application/logic/export/nsf/source/sample.py @@ -0,0 +1,86 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import AbstractSet, Final + +from sampletones_application.config.managers.session import SessionManager +from sampletones_application.logic.export.nsf.protocol import NSFExportServiceProtocol +from sampletones_application.view_model.shared.nsf.offer import NO_FRAMES, NSFExportOffer +from sampletones_core.constants.enums import ChannelName +from sampletones_core.exports.backend import ExportBackend +from sampletones_core.exports.request import SampleExport +from sampletones_core.exports.scope import ExportScope +from sampletones_player.compression.scheme import offered_schemes +from sampletones_player.export.program import NSFProgram + +NO_TICKS: Final[int] = 0 + + +@dataclass(frozen=True) +class SampleSource: + """A reconstruction's slices, written as a program sounding them together once through. + + Attributes: + request: The slices the export writes. + """ + + request: SampleExport + + @property + def scope(self) -> ExportScope: + return ExportScope.SAMPLE + + @property + def name(self) -> str: + return self.request.name + + @property + def nes_frequency(self) -> int: + return self.request.nes_frequency + + def offer(self) -> NSFExportOffer: + """The channels the slices sound on, and the schemes that write slices seeding no dictionary.""" + sounded = {instrument.channel for instrument in self.request.instruments} + return NSFExportOffer( + channels=tuple(channel for channel in ChannelName.items() if channel in sounded), + frame_count=NO_FRAMES, + schemes=offered_schemes(seeded=False), + ) + + def program(self) -> NSFProgram: + return NSFProgram.for_sample(self.request) + + def ticks(self, channels: AbstractSet[ChannelName]) -> int: + """The ticks the longest slice on ``channels`` runs for, which is how long the song lasts.""" + return max( + ( + instrument.features.frame_count + for instrument in self.request.instruments + if instrument.channel in channels + ), + default=NO_TICKS, + ) + + def frame_tick(self, frame: int) -> int: + """The tick order frame ``frame`` starts on. + + Raises: + ValueError: Always, since a reconstruction plays its slices through once, laid out in + no order frames. + """ + raise ValueError(f"A reconstruction is laid out in no order frames, and frame {frame} was asked for") + + def proposed_directory(self, session_manager: SessionManager) -> Path: + """The folder the last instrument was exported to.""" + return session_manager.get_instrument_path() + + def remember_directory(self, session_manager: SessionManager, directory: Path) -> None: + """Opens the next instrument export where this one was written.""" + session_manager.set_instrument_path(directory) + + def submit( + self, + service: NSFExportServiceProtocol, + destination: Path, + backend: ExportBackend, + ) -> None: + service.export_sample(destination, backend, self.request) diff --git a/src/sampletones_application/logic/main/converter/view.py b/src/sampletones_application/logic/main/converter/view.py index 16e5a149d..2b50768cb 100644 --- a/src/sampletones_application/logic/main/converter/view.py +++ b/src/sampletones_application/logic/main/converter/view.py @@ -7,7 +7,6 @@ from sampletones_application.logic.main.converter.state import ConverterState from sampletones_application.logic.main.sources.row import SourceRow from sampletones_application.logic.main.sources.slots import ( - ALL_CHANNELS, CHANNEL_SLOT, SETTINGS_SLOTS, SettingsSlot, @@ -19,7 +18,7 @@ ) from sampletones_application.view_model.shared.agreement import Agreement from sampletones_application.view_model.shared.stems import StemRowViewModel -from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.enums import ALL_CHANNELS, ChannelName from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings diff --git a/src/sampletones_application/logic/main/sources/slots.py b/src/sampletones_application/logic/main/sources/slots.py index 661d6d80f..0d1f3ed22 100644 --- a/src/sampletones_application/logic/main/sources/slots.py +++ b/src/sampletones_application/logic/main/sources/slots.py @@ -2,15 +2,13 @@ from typing import Callable, Dict, Final, FrozenSet, Tuple from sampletones_application.constants.sources import SettingsField -from sampletones_core.constants.enums import TONE_CHANNELS, ChannelName, ordered_channels +from sampletones_core.constants.enums import ALL_CHANNELS, TONE_CHANNELS, ChannelName, ordered_channels from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings SettingsReader = Callable[[StemSettings], FrozenSet[ChannelName]] SettingsWriter = Callable[[StemSettings, FrozenSet[ChannelName]], StemSettings] SettingsOffer = Callable[[StemSettings], FrozenSet[ChannelName]] -ALL_CHANNELS: Final[FrozenSet[ChannelName]] = frozenset(ChannelName.items()) - def _channels_of(settings: StemSettings) -> FrozenSet[ChannelName]: return settings.channel_set diff --git a/src/sampletones_application/logic/reconstruction/reconstruction.py b/src/sampletones_application/logic/reconstruction/reconstruction.py index cd7f4c196..bf7c002e9 100644 --- a/src/sampletones_application/logic/reconstruction/reconstruction.py +++ b/src/sampletones_application/logic/reconstruction/reconstruction.py @@ -489,14 +489,46 @@ def handle_export_instruments_confirmed( logger.warning("No reconstruction data available for instruments export") return - base_name = destination.stem - request = SampleExport( - name=base_name, + request = self._sample_request(reconstruction_data, destination.stem) + self._session_manager.set_instrument_path(destination.parent) + self._export_service.export_sample( + destination, + self._export_backends[export_format], + request, + ) + + def sample_request(self) -> SampleExport: + """The loaded reconstruction's slices as one export, under the reconstruction's own name. + + A format asking for its choices in a setup of its own names the export before a + destination exists, so the slices take the name the reconstruction is known by. + + Raises: + AssertionError: If no reconstruction is loaded. + """ + reconstruction_data = self._reconstruction_data + if not reconstruction_data: + raise AssertionError("Expected reconstruction data to be loaded before exporting instruments") + + return self._sample_request(reconstruction_data, reconstruction_data.name) + + def _sample_request( + self, + reconstruction_data: ReconstructionData, + name: str, + ) -> SampleExport: + """The slice of every playing channel of the reconstruction as one export named ``name``. + + Each slice takes its channel suffix from the name, and a channel standing by describes no + frame and is left to rest. + """ + return SampleExport( + name=name, instruments=tuple( self._instrument_export( channel_name, feature, - instrument_slice_name(base_name, channel_name), + instrument_slice_name(name, channel_name), ) for channel_name, feature in reconstruction_data.feature_data.channels.items() if feature.has_frames @@ -504,12 +536,6 @@ def handle_export_instruments_confirmed( nes_frequency=self._nes_frequency(), tuning=self._tuning(), ) - self._session_manager.set_instrument_path(destination.parent) - self._export_service.export_sample( - destination, - self._export_backends[export_format], - request, - ) def _instrument_export( self, diff --git a/src/sampletones_application/logic/render/logic.py b/src/sampletones_application/logic/render/logic.py index 4789fa31c..7d2a0ead8 100644 --- a/src/sampletones_application/logic/render/logic.py +++ b/src/sampletones_application/logic/render/logic.py @@ -5,7 +5,6 @@ from sampletones_application.config.managers.config import ConfigManager from sampletones_application.config.managers.session import SessionManager from sampletones_application.logic.project.controller import ProjectController -from sampletones_application.logic.sequencer.channels import ALL_CHANNELS from sampletones_application.logic.sequencer.playback.synthesizer import ( RowSynthesizer, SongLength, @@ -31,6 +30,7 @@ available_audio_formats, available_depths, ) +from sampletones_core.constants.enums import ALL_CHANNELS from sampletones_core.parallelization import ETAEstimator from sampletones_shared.constants.project import DEFAULT_EXPORT_NAME from sampletones_shared.logger import logger diff --git a/src/sampletones_application/logic/sequencer/channels.py b/src/sampletones_application/logic/sequencer/channels.py index 1cb2f796e..a56bfbc69 100644 --- a/src/sampletones_application/logic/sequencer/channels.py +++ b/src/sampletones_application/logic/sequencer/channels.py @@ -3,10 +3,9 @@ from sampletones_application.view_model.sequencer.channels import ( SequencerChannelsViewModel, ) -from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.enums import ALL_CHANNELS, ChannelName from sampletones_shared.utils.callbacks import CallbackMixin -ALL_CHANNELS: Final[FrozenSet[ChannelName]] = frozenset(ChannelName.items()) _NO_CHANNELS: Final[FrozenSet[ChannelName]] = frozenset() diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/length.py b/src/sampletones_application/logic/sequencer/playback/synthesizer/length.py index e75fac730..96b90aa5c 100644 --- a/src/sampletones_application/logic/sequencer/playback/synthesizer/length.py +++ b/src/sampletones_application/logic/sequencer/playback/synthesizer/length.py @@ -28,12 +28,11 @@ class SongLength: def measure(cls, project: Project, *, sample_rate: int) -> Self: """The length ``project`` runs to when rendered at ``sample_rate``. - Every pattern holds the song's row count, so one groove covers the whole order and the - tick total is that groove's, once for each position the order plays. + The song ends where the frame one past the order's last would start, which is the tick + total the walk through the song reaches. """ - groove = SongTiming.from_project(project).groove() return cls( - ticks=project.song.order_length() * groove.total_ticks, + ticks=SongTiming.from_project(project).frame_tick(project.song.order_length()), rates=EngineRates.from_project(project, sample_rate), ) diff --git a/src/sampletones_application/tags/settings.py b/src/sampletones_application/tags/settings.py index 5c84aee27..6bec9aac7 100644 --- a/src/sampletones_application/tags/settings.py +++ b/src/sampletones_application/tags/settings.py @@ -390,6 +390,132 @@ Widget.BUTTON, "cancel", ) +TAG_SETTINGS_NSF_WINDOW = TagName( + Page.SETTINGS, + Panel.NSF, + Widget.WINDOW, + "nsf", +) +TAG_SETTINGS_NSF_INPUT_TITLE = TagName( + Page.SETTINGS, + Panel.NSF, + Widget.INPUT, + "title", +) +TAG_SETTINGS_NSF_INPUT_ARTIST = TagName( + Page.SETTINGS, + Panel.NSF, + Widget.INPUT, + "artist", +) +TAG_SETTINGS_NSF_INPUT_COPYRIGHT = TagName( + Page.SETTINGS, + Panel.NSF, + Widget.INPUT, + "copyright", +) +TAG_SETTINGS_NSF_INPUT_LOOP_FRAME = TagName( + Page.SETTINGS, + Panel.NSF, + Widget.INPUT, + "loop_frame", +) +TAG_SETTINGS_NSF_TEXT_TITLE_SIZE = TagName( + Page.SETTINGS, + Panel.NSF, + Widget.TEXT, + "title_size", +) +TAG_SETTINGS_NSF_TEXT_ARTIST_SIZE = TagName( + Page.SETTINGS, + Panel.NSF, + Widget.TEXT, + "artist_size", +) +TAG_SETTINGS_NSF_TEXT_COPYRIGHT_SIZE = TagName( + Page.SETTINGS, + Panel.NSF, + Widget.TEXT, + "copyright_size", +) +TAG_SETTINGS_NSF_CHECKBOX_CHANNEL = TagName( + Page.SETTINGS, + Panel.NSF, + Widget.CHECKBOX, + "channel", +) +TAG_SETTINGS_NSF_TEXT_NO_CHANNEL = TagName( + Page.SETTINGS, + Panel.NSF, + Widget.TEXT, + "no_channel", +) +TAG_SETTINGS_NSF_COMBO_REPEAT = TagName( + Page.SETTINGS, + Panel.NSF, + Widget.COMBO, + "repeat", +) +TAG_SETTINGS_NSF_GROUP_LOOP_FRAME = TagName( + Page.SETTINGS, + Panel.NSF, + Widget.GROUP, + "loop_frame", +) +TAG_SETTINGS_NSF_TEXT_FRAME_COUNT = TagName( + Page.SETTINGS, + Panel.NSF, + Widget.TEXT, + "frame_count", +) +TAG_SETTINGS_NSF_TEXT_LENGTH = TagName( + Page.SETTINGS, + Panel.NSF, + Widget.TEXT, + "length", +) +TAG_SETTINGS_NSF_COMBO_SCHEME = TagName( + Page.SETTINGS, + Panel.NSF, + Widget.COMBO, + "scheme", +) +TAG_SETTINGS_NSF_TEXT_SCHEME_DESCRIPTION = TagName( + Page.SETTINGS, + Panel.NSF, + Widget.TEXT, + "scheme_description", +) +TAG_SETTINGS_NSF_GROUP_DESTINATION = TagName( + Page.SETTINGS, + Panel.NSF, + Widget.GROUP, + "destination", +) +TAG_SETTINGS_NSF_PATH_DESTINATION = TagName( + Page.SETTINGS, + Panel.NSF, + Widget.PATH, + "destination", +) +TAG_SETTINGS_NSF_BUTTON_BROWSE = TagName( + Page.SETTINGS, + Panel.NSF, + Widget.BUTTON, + "browse", +) +TAG_SETTINGS_NSF_BUTTON_CANCEL = TagName( + Page.SETTINGS, + Panel.NSF, + Widget.BUTTON, + "cancel", +) +TAG_SETTINGS_NSF_BUTTON_EXPORT = TagName( + Page.SETTINGS, + Panel.NSF, + Widget.BUTTON, + "export", +) TAG_SETTINGS_EXPORT_WINDOW = TagName( Page.SETTINGS, Panel.EXPORT, diff --git a/src/sampletones_application/ui/panels/dialogs/nsf.py b/src/sampletones_application/ui/panels/dialogs/nsf.py new file mode 100644 index 000000000..be6c633fe --- /dev/null +++ b/src/sampletones_application/ui/panels/dialogs/nsf.py @@ -0,0 +1,496 @@ +from dataclasses import dataclass +from typing import Any, Callable, Dict, Final, Optional, Tuple + +import dearpygui.dearpygui as dpg + +from sampletones_application.categories.context import channel_label +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.layout.general.colors.path import PathColors +from sampletones_application.layout.general.colors.text import TextColors +from sampletones_application.layout.settings import SettingsLayout +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import SUF_HANDLER_REGISTRY +from sampletones_application.tags.settings import ( + TAG_SETTINGS_NSF_BUTTON_BROWSE, + TAG_SETTINGS_NSF_BUTTON_CANCEL, + TAG_SETTINGS_NSF_BUTTON_EXPORT, + TAG_SETTINGS_NSF_CHECKBOX_CHANNEL, + TAG_SETTINGS_NSF_COMBO_REPEAT, + TAG_SETTINGS_NSF_COMBO_SCHEME, + TAG_SETTINGS_NSF_GROUP_DESTINATION, + TAG_SETTINGS_NSF_GROUP_LOOP_FRAME, + TAG_SETTINGS_NSF_INPUT_ARTIST, + TAG_SETTINGS_NSF_INPUT_COPYRIGHT, + TAG_SETTINGS_NSF_INPUT_LOOP_FRAME, + TAG_SETTINGS_NSF_INPUT_TITLE, + TAG_SETTINGS_NSF_PATH_DESTINATION, + TAG_SETTINGS_NSF_TEXT_ARTIST_SIZE, + TAG_SETTINGS_NSF_TEXT_COPYRIGHT_SIZE, + TAG_SETTINGS_NSF_TEXT_FRAME_COUNT, + TAG_SETTINGS_NSF_TEXT_LENGTH, + TAG_SETTINGS_NSF_TEXT_NO_CHANNEL, + TAG_SETTINGS_NSF_TEXT_SCHEME_DESCRIPTION, + TAG_SETTINGS_NSF_TEXT_TITLE_SIZE, + TAG_SETTINGS_NSF_WINDOW, +) +from sampletones_application.ui.elements.button import GUIButton +from sampletones_application.ui.elements.dialog import GUIDialogWindow +from sampletones_application.ui.elements.field import labeled_field, subheader +from sampletones_application.ui.elements.fonts.font import Font +from sampletones_application.ui.elements.fonts.registry import FontRegistry +from sampletones_application.ui.elements.path import GUIDestinationPathText +from sampletones_application.ui.elements.status import GUIStatusBar +from sampletones_application.ui.themes.channels import CHANNEL_THEME_TAGS +from sampletones_application.ui.themes.registry import ThemeRegistry +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, dpg_delete_item, dpg_set_value +from sampletones_application.utils.gui.keyboard import KeyRouter +from sampletones_application.utils.gui.keyboard.focus.tree import is_item_active +from sampletones_application.utils.gui.palette.dpg import dpg_set_palette_color +from sampletones_application.utils.gui.shortcuts.source import ShortcutSource +from sampletones_application.view_model.shared.nsf.choices import NSFExportChoices +from sampletones_application.view_model.shared.nsf.repeat import NSFRepeat +from sampletones_application.view_model.shared.nsf.view import NSFExportViewModel +from sampletones_core.constants.enums import ChannelName +from sampletones_player.compression.scheme import CompressionScheme +from sampletones_player.nsf.information import NSFInformation +from sampletones_shared.types.application import Sender +from sampletones_shared.types.callback import VoidCallback + +HEXADECIMAL_BASE: Final[int] = 16 + +ChoicesCallback = Callable[[NSFExportChoices], None] + + +@dataclass(frozen=True) +class HeaderField: + """One of the header's text fields, as the dialog lays it out and edits it. + + Attributes: + input_tag: The field the text is typed into. + size_tag: The line stating the bytes the text takes. + label: What the field is known by. + read: The field's text, out of the header information. + edit: The choices holding a typed text in the field. + """ + + input_tag: str + size_tag: str + label: str + read: Callable[[NSFInformation], str] + edit: Callable[[NSFExportChoices, str], NSFExportChoices] + + +class GUINSFExportWindow(GUIDialogWindow): + """Modal form over writing a project or a reconstruction as an NSF program. + + The dialog sets the export up and hands it over: the program's text, the channels it sounds, + how it repeats and how hard it is compressed are chosen here along with the file, and the + export's own window reports the run that follows. + + Every control reports the whole edited choices through ``on_choices_changed``, which the owner + reconciles and hands back, so the dialog shows the choices as they end up. A field being typed + into keeps the text the reader types, and takes the text the header holds once it is left. + """ + + _fits_content = True + + def __init__( + self, + *, + layout: SettingsLayout, + path_colors: PathColors, + text_colors: TextColors, + language_manager: LanguageManager, + key_router: KeyRouter, + shortcut_source: ShortcutSource, + status_bar: GUIStatusBar, + ) -> None: + self._language_manager = language_manager + self._layout = layout + self._path_colors = path_colors + self._text_colors = text_colors + self._status_bar = status_bar + self._view_model: Optional[NSFExportViewModel] = None + self._destination_text: Optional[GUIDestinationPathText] = None + self._handler_tag = compose_tag(TAG_SETTINGS_NSF_WINDOW, SUF_HANDLER_REGISTRY) + + self.on_choices_changed: Optional[ChoicesCallback] = None + self.on_browse: Optional[VoidCallback] = None + self.on_export: Optional[VoidCallback] = None + self.on_close: Optional[VoidCallback] = None + + self._repeats_by_label: Dict[str, NSFRepeat] = {} + self._schemes_by_label: Dict[str, CompressionScheme] = {} + + self._fmt_text_size = language_manager["settings.nsf.template.text_size"] + self._fmt_frame = language_manager["settings.nsf.template.frame"] + self._fmt_frame_count = language_manager["settings.nsf.template.frame_count"] + self._fmt_length = language_manager["settings.nsf.template.length"] + self._msg_destination = language_manager["global.status.message.destination"] + self._fields: Tuple[HeaderField, ...] = ( + HeaderField( + input_tag=TAG_SETTINGS_NSF_INPUT_TITLE, + size_tag=TAG_SETTINGS_NSF_TEXT_TITLE_SIZE, + label=language_manager["settings.nsf.label.title"], + read=lambda information: information.title, + edit=NSFExportChoices.with_title, + ), + HeaderField( + input_tag=TAG_SETTINGS_NSF_INPUT_ARTIST, + size_tag=TAG_SETTINGS_NSF_TEXT_ARTIST_SIZE, + label=language_manager["settings.nsf.label.artist"], + read=lambda information: information.artist, + edit=NSFExportChoices.with_artist, + ), + HeaderField( + input_tag=TAG_SETTINGS_NSF_INPUT_COPYRIGHT, + size_tag=TAG_SETTINGS_NSF_TEXT_COPYRIGHT_SIZE, + label=language_manager["settings.nsf.label.copyright"], + read=lambda information: information.copyright, + edit=NSFExportChoices.with_copyright, + ), + ) + self._repeat_labels: Dict[NSFRepeat, str] = { + NSFRepeat.ONCE: language_manager["settings.nsf.label.repeat_once"], + NSFRepeat.FROM_START: language_manager["settings.nsf.label.repeat_from_start"], + NSFRepeat.FROM_FRAME: language_manager["settings.nsf.label.repeat_from_frame"], + } + self._scheme_labels: Dict[CompressionScheme, str] = { + CompressionScheme.NONE: language_manager["settings.nsf.label.scheme_none"], + CompressionScheme.RUNS: language_manager["settings.nsf.label.scheme_runs"], + CompressionScheme.INSTRUMENTS: language_manager["settings.nsf.label.scheme_instruments"], + CompressionScheme.SEARCH: language_manager["settings.nsf.label.scheme_search"], + } + self._scheme_descriptions: Dict[CompressionScheme, str] = { + CompressionScheme.NONE: language_manager["settings.nsf.message.scheme_none"], + CompressionScheme.RUNS: language_manager["settings.nsf.message.scheme_runs"], + CompressionScheme.INSTRUMENTS: language_manager["settings.nsf.message.scheme_instruments"], + CompressionScheme.SEARCH: language_manager["settings.nsf.message.scheme_search"], + } + + super().__init__( + tag=TAG_SETTINGS_NSF_WINDOW, + width=layout.nsf.window.width, + height=layout.nsf.window.height, + key_router=key_router, + shortcut_source=shortcut_source, + ) + + def open(self, view_model: NSFExportViewModel) -> None: + """Shows the window seeded with the export being set up.""" + self._view_model = view_model + self.show() + + def prepare(self, *_args: Any, **_kwargs: Any) -> None: + """The drawn values are seeded by :meth:`open` before the tree rebuilds.""" + + def update_view(self, view_model: NSFExportViewModel) -> None: + """Re-draws the open window from the choices the export stands at.""" + self._view_model = view_model + self._render() + + def create_window(self) -> None: + with self.dialog_window( + label=self._language_manager["settings.nsf.title.window_title"], + on_close=self._request_close, + ): + self._create_program_section() + dpg.add_separator() + self._create_channels_section() + dpg.add_separator() + self._create_playback_section() + dpg.add_separator() + self._create_compression_section() + dpg.add_separator() + self._create_destination() + dpg.add_separator() + self._create_buttons() + + self._create_field_handlers() + self._bind_dialog_theme( + TAG_SETTINGS_NSF_INPUT_TITLE, + TAG_SETTINGS_NSF_INPUT_ARTIST, + TAG_SETTINGS_NSF_INPUT_COPYRIGHT, + TAG_SETTINGS_NSF_COMBO_REPEAT, + TAG_SETTINGS_NSF_INPUT_LOOP_FRAME, + TAG_SETTINGS_NSF_COMBO_SCHEME, + ) + + self._render() + self._install_navigation( + [ + *(FocusStop.field(field.input_tag) for field in self._fields), + *(FocusStop.field(self._channel_tag(channel)) for channel in ChannelName.items()), + FocusStop.field(TAG_SETTINGS_NSF_COMBO_REPEAT), + FocusStop.field(TAG_SETTINGS_NSF_INPUT_LOOP_FRAME), + FocusStop.field(TAG_SETTINGS_NSF_COMBO_SCHEME), + FocusStop.button(TAG_SETTINGS_NSF_BUTTON_BROWSE, self._request_destination), + FocusStop.button(TAG_SETTINGS_NSF_BUTTON_CANCEL, self._request_close), + FocusStop.button(TAG_SETTINGS_NSF_BUTTON_EXPORT, self._request_export), + ], + on_escape=self._request_close, + ) + + def _create_program_section(self) -> None: + subheader(self._language_manager["settings.nsf.title.section_program"]) + for field in self._fields: + with labeled_field(field.label, self._layout.label_width): + dpg.add_input_text( + tag=field.input_tag, + width=self._layout.nsf.text_width, + callback=self._on_text_changed, + user_data=field, + ) + dpg.add_text("", tag=field.size_tag) + + FontRegistry.bind_to_item(field.size_tag, Font.MONO_SMALL) + dpg_set_palette_color(field.size_tag, self._text_colors.disabled) + + def _create_channels_section(self) -> None: + subheader(self._language_manager["settings.nsf.title.section_channels"]) + self._create_channel_checkboxes() + dpg.add_text( + self._language_manager["settings.nsf.message.no_channel"], + tag=TAG_SETTINGS_NSF_TEXT_NO_CHANNEL, + ) + dpg_set_palette_color(TAG_SETTINGS_NSF_TEXT_NO_CHANNEL, self._text_colors.highlight) + + @table_wrapper(columns=len(ChannelName.items()), height=0) + def _create_channel_checkboxes(self) -> None: + """Lays the channels out in one row, each tinted in its own color where the source sounds it.""" + view_model = self._require_view_model() + for channel in ChannelName.items(): + tag = self._channel_tag(channel) + dpg.add_checkbox( + tag=tag, + label=channel_label(self._language_manager, channel), + callback=self._on_channel_changed, + user_data=channel, + ) + if view_model.channel_offered(channel): + ThemeRegistry.get(CHANNEL_THEME_TAGS[channel]).bind_to_item(tag) + + def _create_playback_section(self) -> None: + subheader(self._language_manager["settings.nsf.title.section_playback"]) + with labeled_field( + self._language_manager["settings.nsf.label.repeat"], + self._layout.label_width, + ): + dpg.add_combo( + tag=TAG_SETTINGS_NSF_COMBO_REPEAT, + items=[], + width=self._layout.combo_width, + callback=self._on_repeat_changed, + ) + + with ( + dpg.group(tag=TAG_SETTINGS_NSF_GROUP_LOOP_FRAME), + labeled_field( + self._language_manager["settings.nsf.label.loop_frame"], + self._layout.label_width, + ), + ): + dpg.add_input_text( + tag=TAG_SETTINGS_NSF_INPUT_LOOP_FRAME, + width=self._layout.nsf.frame_width, + hexadecimal=True, + uppercase=True, + no_spaces=True, + callback=self._on_loop_frame_changed, + ) + dpg.add_text("", tag=TAG_SETTINGS_NSF_TEXT_FRAME_COUNT) + + FontRegistry.bind_to_item(TAG_SETTINGS_NSF_INPUT_LOOP_FRAME, Font.MONO) + FontRegistry.bind_to_item(TAG_SETTINGS_NSF_TEXT_FRAME_COUNT, Font.MONO) + with labeled_field( + self._language_manager["settings.nsf.label.length"], + self._layout.label_width, + ): + dpg.add_text("", tag=TAG_SETTINGS_NSF_TEXT_LENGTH) + FontRegistry.bind_to_item(TAG_SETTINGS_NSF_TEXT_LENGTH, Font.MONO) + + def _create_compression_section(self) -> None: + subheader(self._language_manager["settings.nsf.title.section_compression"]) + with labeled_field( + self._language_manager["settings.nsf.label.scheme"], + self._layout.label_width, + ): + dpg.add_combo( + tag=TAG_SETTINGS_NSF_COMBO_SCHEME, + items=[], + width=self._layout.combo_width, + callback=self._on_scheme_changed, + ) + + dpg.add_text( + "", + tag=TAG_SETTINGS_NSF_TEXT_SCHEME_DESCRIPTION, + indent=self._layout.label_width, + wrap=0, + ) + FontRegistry.bind_to_item(TAG_SETTINGS_NSF_TEXT_SCHEME_DESCRIPTION, Font.REGULAR_SMALL) + dpg_set_palette_color(TAG_SETTINGS_NSF_TEXT_SCHEME_DESCRIPTION, self._text_colors.disabled) + + def _create_destination(self) -> None: + """Lays out the file the export writes, with the browse button leading the path it stands at.""" + with labeled_field( + self._language_manager["settings.nsf.label.destination"], + self._layout.label_width, + ): + dpg.add_group(horizontal=True, tag=TAG_SETTINGS_NSF_GROUP_DESTINATION) + + GUIButton( + tag=TAG_SETTINGS_NSF_BUTTON_BROWSE, + label=self._language_manager["settings.nsf.label.browse_button"], + parent=TAG_SETTINGS_NSF_GROUP_DESTINATION, + callback=self._request_destination, + ) + self._destination_text = GUIDestinationPathText( + tag=TAG_SETTINGS_NSF_PATH_DESTINATION, + path=self._require_view_model().destination, + parent=TAG_SETTINGS_NSF_GROUP_DESTINATION, + color=self._path_colors.default, + hover_color=self._path_colors.hover, + status_message=self._msg_destination, + font=Font.REGULAR_SMALL, + status_bar=self._status_bar, + ) + + @table_wrapper(columns=2) + def _create_buttons(self) -> None: + GUIButton( + tag=TAG_SETTINGS_NSF_BUTTON_CANCEL, + label=self._language_manager["global.dialog.label.cancel"], + callback=self._request_close, + width=-1, + ) + GUIButton( + tag=TAG_SETTINGS_NSF_BUTTON_EXPORT, + label=self._language_manager["settings.nsf.label.export_button"], + callback=self._request_export, + width=-1, + ) + + def _create_field_handlers(self) -> None: + """Draws a typed field again once it is left, so it shows the text the header holds.""" + with dpg.item_handler_registry(tag=self._handler_tag): + dpg.add_item_deactivated_after_edit_handler(callback=self._render) + + for tag in (*(field.input_tag for field in self._fields), TAG_SETTINGS_NSF_INPUT_LOOP_FRAME): + dpg.bind_item_handler_registry(tag, self._handler_tag) + + def _render(self) -> None: + """Draws the choices as they reconciled, around whatever field is being typed into.""" + view_model = self._require_view_model() + self._render_fields(view_model) + self._render_channels(view_model) + self._render_repeat(view_model) + self._render_scheme(view_model) + if self._destination_text is not None: + self._destination_text.set_path(view_model.destination) + + dpg_configure_item(TAG_SETTINGS_NSF_BUTTON_EXPORT, enabled=view_model.export_enabled) + + def _render_fields(self, view_model: NSFExportViewModel) -> None: + for field in self._fields: + text = field.read(view_model.choices.information) + self._settle_input(field.input_tag, text) + dpg_set_value(field.size_tag, view_model.text_size_label(text, self._fmt_text_size)) + + def _render_channels(self, view_model: NSFExportViewModel) -> None: + for channel in ChannelName.items(): + tag = self._channel_tag(channel) + dpg_configure_item(tag, enabled=view_model.channel_offered(channel)) + dpg_set_value(tag, view_model.channel_sounded(channel)) + + dpg_configure_item(TAG_SETTINGS_NSF_TEXT_NO_CHANNEL, show=not view_model.export_enabled) + + def _render_repeat(self, view_model: NSFExportViewModel) -> None: + self._repeats_by_label = {self._repeat_labels[repeat]: repeat for repeat in view_model.repeats} + dpg_configure_item(TAG_SETTINGS_NSF_COMBO_REPEAT, items=list(self._repeats_by_label)) + dpg_set_value(TAG_SETTINGS_NSF_COMBO_REPEAT, self._repeat_labels[view_model.choices.repeat]) + dpg_configure_item(TAG_SETTINGS_NSF_GROUP_LOOP_FRAME, show=view_model.loop_frame_visible) + dpg_configure_item(TAG_SETTINGS_NSF_INPUT_LOOP_FRAME, enabled=view_model.loop_frame_visible) + self._settle_input(TAG_SETTINGS_NSF_INPUT_LOOP_FRAME, view_model.loop_frame_label(self._fmt_frame)) + dpg_set_value(TAG_SETTINGS_NSF_TEXT_FRAME_COUNT, view_model.frame_count_label(self._fmt_frame_count)) + dpg_set_value(TAG_SETTINGS_NSF_TEXT_LENGTH, view_model.length_label(self._fmt_length)) + + def _render_scheme(self, view_model: NSFExportViewModel) -> None: + scheme = view_model.choices.scheme + self._schemes_by_label = {self._scheme_labels[offered]: offered for offered in view_model.schemes} + dpg_configure_item(TAG_SETTINGS_NSF_COMBO_SCHEME, items=list(self._schemes_by_label)) + dpg_set_value(TAG_SETTINGS_NSF_COMBO_SCHEME, self._scheme_labels[scheme]) + dpg_set_value(TAG_SETTINGS_NSF_TEXT_SCHEME_DESCRIPTION, self._scheme_descriptions[scheme]) + + @staticmethod + def _settle_input(tag: str, text: str) -> None: + """Puts ``text`` in a field the reader has left. + + DearPyGui reports a field's typed text again on every frame once its value is set while it + is being typed into, so the field being edited keeps its own text until it is left. + """ + if dpg.does_item_exist(tag) and not is_item_active(tag): + dpg_set_value(tag, text) + + def _on_text_changed(self, _sender: Sender, app_data: str, field: HeaderField) -> None: + self._emit(field.edit(self._choices(), app_data)) + + def _on_channel_changed(self, _sender: Sender, app_data: bool, channel: ChannelName) -> None: + view_model = self._require_view_model() + self._emit(view_model.choices.with_channel(channel, bool(app_data), view_model.offer)) + + def _on_repeat_changed(self, _sender: Sender, app_data: str) -> None: + view_model = self._require_view_model() + self._emit(view_model.choices.with_repeat(self._repeats_by_label[app_data], view_model.offer)) + + def _on_loop_frame_changed(self, _sender: Sender, app_data: str) -> None: + """Takes the frame typed in hexadecimal, once the field holds a number to read.""" + try: + frame = int(app_data, HEXADECIMAL_BASE) + except ValueError: + return + + view_model = self._require_view_model() + self._emit(view_model.choices.with_loop_frame(frame, view_model.offer)) + + def _on_scheme_changed(self, _sender: Sender, app_data: str) -> None: + view_model = self._require_view_model() + self._emit(view_model.choices.with_scheme(self._schemes_by_label[app_data], view_model.offer)) + + def _emit(self, choices: NSFExportChoices) -> None: + self.call(self.on_choices_changed, choices) + + def _request_destination(self) -> None: + self.call(self.on_browse) + + def _request_export(self) -> None: + if self._require_view_model().export_enabled: + self.call(self.on_export) + + def _request_close(self) -> None: + self.call(self.on_close) + + def _teardown(self) -> None: + """Releases the keyboard claim and the field handlers of the appearance being torn down.""" + super()._teardown() + dpg_delete_item(self._handler_tag) + + def _choices(self) -> NSFExportChoices: + return self._require_view_model().choices + + @staticmethod + def _channel_tag(channel: ChannelName) -> str: + return compose_tag(TAG_SETTINGS_NSF_CHECKBOX_CHANNEL, channel.value) + + def _require_view_model(self) -> NSFExportViewModel: + """The export on screen. + + Raises: + SystemError: when the window is drawn before :meth:`open` seeds it. + """ + if self._view_model is None: + raise SystemError("The NSF export window is drawn from a view model it was opened with") + + return self._view_model diff --git a/src/sampletones_application/view_model/shared/nsf/__init__.py b/src/sampletones_application/view_model/shared/nsf/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_application/view_model/shared/nsf/choices.py b/src/sampletones_application/view_model/shared/nsf/choices.py new file mode 100644 index 000000000..fc6768e2a --- /dev/null +++ b/src/sampletones_application/view_model/shared/nsf/choices.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +from typing import FrozenSet, Optional + +from pydantic import BaseModel + +from sampletones_application.view_model.shared.nsf.offer import FIRST_FRAME, NSFExportOffer +from sampletones_application.view_model.shared.nsf.repeat import NSFRepeat +from sampletones_core.constants.enums import ChannelName +from sampletones_player.builder import SONG_START +from sampletones_player.compression.scheme import CompressionScheme +from sampletones_player.export.program import NSFProgram +from sampletones_player.nsf.information import NSFInformation + + +class NSFExportChoices(BaseModel, frozen=True): + """The choices a program is written under, as the export dialog edits them. + + The player owns what a program may state, so the text is held as the header fields it becomes, + cut to the room each has, and every ``with_`` method reconciles its edit against what the source + offers: a channel the source leaves silent stays unticked, a repeat returns to a frame the song + has, and a scheme is one that writes the source differently. Every value held here is therefore + one the program is written with. + + Attributes: + information: The text the file is listed under. + channels: The channels ticked for the program to sound. + repeat: Where the program goes once its song ends. + loop_frame: The order frame a repeat from a frame returns to. + scheme: The layers of the codec the song is written with. + """ + + information: NSFInformation + channels: FrozenSet[ChannelName] + repeat: NSFRepeat + loop_frame: int + scheme: CompressionScheme + + @classmethod + def initial(cls, program: NSFProgram, offer: NSFExportOffer) -> NSFExportChoices: + """The choices a dialog opens on: the program an export writes as its source states it. + + Args: + program: The program the source states. + offer: What the source leaves to choose. + + Returns: + NSFExportChoices: The stated program, its channels narrowed to the ones the source sounds. + + Raises: + ValueError: If the program repeats from a tick past the song's start. + """ + return cls( + information=program.information, + channels=program.channels.intersection(offer.channels), + repeat=cls._stated_repeat(program.loop_tick), + loop_frame=FIRST_FRAME, + scheme=program.scheme, + ) + + @property + def writable(self) -> bool: + """Whether a program is written under these choices, which a ticked channel is what makes.""" + return bool(self.channels) + + def with_title(self, title: str) -> NSFExportChoices: + """The choices listing the file under ``title``, as much of it as the field holds.""" + return self._with_information( + NSFInformation( + title=title, + artist=self.information.artist, + copyright=self.information.copyright, + ) + ) + + def with_artist(self, artist: str) -> NSFExportChoices: + """The choices crediting the file to ``artist``, as much of it as the field holds.""" + return self._with_information( + NSFInformation( + title=self.information.title, + artist=artist, + copyright=self.information.copyright, + ) + ) + + def with_copyright(self, copyright_text: str) -> NSFExportChoices: + """The choices stating ``copyright_text`` as the rights, as much of it as the field holds.""" + return self._with_information( + NSFInformation( + title=self.information.title, + artist=self.information.artist, + copyright=copyright_text, + ) + ) + + def with_channel( + self, + channel: ChannelName, + sounded: bool, + offer: NSFExportOffer, + ) -> NSFExportChoices: + """The choices sounding ``channel`` or resting it, among the channels the source sounds. + + Args: + channel: The channel ticked or unticked. + sounded: Whether the program sounds it. + offer: What the source leaves to choose. + + Returns: + NSFExportChoices: The choices with the channel ticked as asked where the source sounds + it, and the choices as they stand otherwise. + """ + if channel not in offer.channels: + return self + + channels = self.channels | {channel} if sounded else self.channels - {channel} + return self.model_copy(update={"channels": channels}) + + def with_repeat(self, repeat: NSFRepeat, offer: NSFExportOffer) -> NSFExportChoices: + """The choices going on as ``repeat`` states, where the source offers it. + + Returns: + NSFExportChoices: The choices with the repeat as asked where the source offers it, and + the choices as they stand otherwise. + """ + if repeat not in offer.repeats: + return self + + return self.model_copy(update={"repeat": repeat}) + + def with_loop_frame(self, frame: int, offer: NSFExportOffer) -> NSFExportChoices: + """The choices returning to ``frame``, brought within the frames the song is laid out in.""" + return self.model_copy(update={"loop_frame": min(max(frame, FIRST_FRAME), offer.last_frame)}) + + def with_scheme(self, scheme: CompressionScheme, offer: NSFExportOffer) -> NSFExportChoices: + """The choices writing the song under ``scheme``, where the source offers it. + + Returns: + NSFExportChoices: The choices with the scheme as asked where the source offers it, and + the choices as they stand otherwise. + """ + if scheme not in offer.schemes: + return self + + return self.model_copy(update={"scheme": scheme}) + + def _with_information(self, information: NSFInformation) -> NSFExportChoices: + return self.model_copy(update={"information": information}) + + @staticmethod + def _stated_repeat(loop_tick: Optional[int]) -> NSFRepeat: + """The repeat a stated program's loop reads as: once without one, and from the start with one. + + Raises: + ValueError: If the loop returns to a tick past the song's start. + """ + match loop_tick: + case None: + return NSFRepeat.ONCE + case tick if tick == SONG_START: + return NSFRepeat.FROM_START + case tick: + raise ValueError(f"A stated program repeats from the song's start, and this one repeats from {tick}") diff --git a/src/sampletones_application/view_model/shared/nsf/offer.py b/src/sampletones_application/view_model/shared/nsf/offer.py new file mode 100644 index 000000000..f39dec3d7 --- /dev/null +++ b/src/sampletones_application/view_model/shared/nsf/offer.py @@ -0,0 +1,42 @@ +from typing import Final, Tuple + +from pydantic import BaseModel + +from sampletones_application.view_model.shared.nsf.repeat import NSFRepeat +from sampletones_core.constants.enums import ChannelName +from sampletones_player.compression.scheme import CompressionScheme + +FIRST_FRAME: Final[int] = 0 +NO_FRAMES: Final[int] = 0 + + +class NSFExportOffer(BaseModel, frozen=True): + """What one source leaves to choose when it is written as a program. + + A project lays its song out in order frames and seeds the dictionary with its samples, while a + reconstruction plays its slices once through and seeds nothing, so the choices worth offering + follow the source: the channels it sounds, the frames a repeat can return to and the schemes + that write it differently. + + Attributes: + channels: The channels the source sounds, in channel order. + frame_count: The order frames the song is laid out in, none for a reconstruction. + schemes: The compression schemes that write the source differently, lightest first. + """ + + channels: Tuple[ChannelName, ...] + frame_count: int + schemes: Tuple[CompressionScheme, ...] + + @property + def last_frame(self) -> int: + """The frame a repeat reaches furthest into the song.""" + return max(self.frame_count - 1, FIRST_FRAME) + + @property + def repeats(self) -> Tuple[NSFRepeat, ...]: + """The ways the program goes on at its end, a return to a frame offered where frames exist.""" + if self.frame_count > NO_FRAMES: + return (NSFRepeat.ONCE, NSFRepeat.FROM_START, NSFRepeat.FROM_FRAME) + + return (NSFRepeat.ONCE, NSFRepeat.FROM_START) diff --git a/src/sampletones_application/view_model/shared/nsf/repeat.py b/src/sampletones_application/view_model/shared/nsf/repeat.py new file mode 100644 index 000000000..bea04f94e --- /dev/null +++ b/src/sampletones_application/view_model/shared/nsf/repeat.py @@ -0,0 +1,9 @@ +from enum import StrEnum + + +class NSFRepeat(StrEnum): + """Where a program goes once its song reaches the end.""" + + ONCE = "once" + FROM_START = "from_start" + FROM_FRAME = "from_frame" diff --git a/src/sampletones_application/view_model/shared/nsf/view.py b/src/sampletones_application/view_model/shared/nsf/view.py new file mode 100644 index 000000000..6d6130da3 --- /dev/null +++ b/src/sampletones_application/view_model/shared/nsf/view.py @@ -0,0 +1,92 @@ +from pathlib import Path +from typing import Tuple + +from pydantic import BaseModel + +from sampletones_application.view_model.shared.nsf.choices import NSFExportChoices +from sampletones_application.view_model.shared.nsf.offer import NSFExportOffer +from sampletones_application.view_model.shared.nsf.repeat import NSFRepeat +from sampletones_core.constants.enums import ChannelName +from sampletones_player.compression.scheme import CompressionScheme +from sampletones_player.nsf.information import field_size +from sampletones_player.specification.nsf import STRING_TEXT_SIZE + + +class NSFExportViewModel(BaseModel, frozen=True): + """What the NSF export dialog draws: what the source offers, the choices standing, and the file. + + The dialog stands while an export is set up, and the run that follows reports through the + export's own window, so everything here describes the setup. + + Attributes: + offer: What the source leaves to choose. + choices: The choices the dialog is standing at. + destination: The file the export writes. + ticks: The ticks the song lasts with the ticked channels sounding. + nes_frequency: The rate in Hz the console plays those ticks at. + """ + + offer: NSFExportOffer + choices: NSFExportChoices + destination: Path + ticks: int + nes_frequency: int + + @property + def channels(self) -> Tuple[ChannelName, ...]: + return self.offer.channels + + @property + def repeats(self) -> Tuple[NSFRepeat, ...]: + return self.offer.repeats + + @property + def schemes(self) -> Tuple[CompressionScheme, ...]: + return self.offer.schemes + + @property + def last_frame(self) -> int: + return self.offer.last_frame + + @property + def loop_frame_visible(self) -> bool: + """Whether a frame is chosen here, which a repeat returning to one is what asks for.""" + return self.choices.repeat == NSFRepeat.FROM_FRAME + + @property + def export_enabled(self) -> bool: + """Whether the program is written from here: at least one channel sounds in it.""" + return self.choices.writable + + @property + def duration_seconds(self) -> float: + """How long the song plays for, in seconds.""" + return self.ticks / self.nes_frequency + + def channel_offered(self, channel: ChannelName) -> bool: + """Whether ``channel`` takes a tick, which the source sounding it is what decides.""" + return channel in self.offer.channels + + def channel_sounded(self, channel: ChannelName) -> bool: + """Whether the program sounds ``channel``.""" + return channel in self.choices.channels + + def length_label(self, template: str) -> str: + """How long the song plays for, in seconds and in ticks, as ``template`` states it.""" + return template.format( + seconds=self.duration_seconds, + ticks=self.ticks, + rate=self.nes_frequency, + ) + + def text_size_label(self, text: str, template: str) -> str: + """The bytes ``text`` takes in its header field against the room there, as ``template`` states them.""" + return template.format(used=field_size(text), room=STRING_TEXT_SIZE) + + def loop_frame_label(self, template: str) -> str: + """The order frame a repeat returns to, as ``template`` states it.""" + return template.format(frame=self.choices.loop_frame) + + def frame_count_label(self, template: str) -> str: + """The frames the song is laid out in, as ``template`` states them.""" + return template.format(count=self.offer.frame_count) diff --git a/src/sampletones_config/boundaries/graphs.yaml b/src/sampletones_config/boundaries/graphs.yaml index 593e11c7e..54a9fb886 100644 --- a/src/sampletones_config/boundaries/graphs.yaml +++ b/src/sampletones_config/boundaries/graphs.yaml @@ -21,5 +21,5 @@ player: song.py: [clock, registers, compression] builder.py: [song.py, registers, clock, compression] nsf: [song.py, specification, compression, driver] - export.py: [builder.py, nsf, driver, compression] + export: [builder.py, song.py, nsf, driver, compression] driver: [specification] diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 9f2902567..755f196ea 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -847,6 +847,37 @@ settings.export.label.stage_compressing: "Compressing the song" settings.export.label.stage_writing: "Writing the file" settings.export.template.size: "{completed} of {total} bytes" settings.export.message.status_canceling: "Stopping the export..." +settings.nsf.title.window_title: "Export NSF program" +settings.nsf.title.section_program: "Program" +settings.nsf.title.section_channels: "Channels" +settings.nsf.title.section_playback: "Playback" +settings.nsf.title.section_compression: "Compression" +settings.nsf.label.title: "Title" +settings.nsf.label.artist: "Artist" +settings.nsf.label.copyright: "Copyright" +settings.nsf.label.repeat: "Repeat" +settings.nsf.label.repeat_once: "Play once" +settings.nsf.label.repeat_from_start: "From the start" +settings.nsf.label.repeat_from_frame: "From a frame" +settings.nsf.label.loop_frame: "Frame" +settings.nsf.label.length: "Length" +settings.nsf.label.scheme: "Level" +settings.nsf.label.scheme_none: "None" +settings.nsf.label.scheme_runs: "Held sounds" +settings.nsf.label.scheme_instruments: "Samples" +settings.nsf.label.scheme_search: "Full search" +settings.nsf.label.destination: "File" +settings.nsf.label.browse_button: "Browse..." +settings.nsf.label.export_button: "Export" +settings.nsf.message.no_channel: "Select at least one channel to export." +settings.nsf.message.scheme_none: "The song is saved without compression. The fastest export, but the largest file." +settings.nsf.message.scheme_runs: "A sound that stays the same is saved once, instead of many times a second." +settings.nsf.message.scheme_instruments: "Each sample is saved once, and the song points to it every time it plays. Held sounds are saved once too." +settings.nsf.message.scheme_search: "Every part of the song that repeats is saved once. The slowest export, but the smallest file." +settings.nsf.template.text_size: "{used}/{room}" +settings.nsf.template.frame: "{frame:02X}" +settings.nsf.template.frame_count: "of {count:02X}" +settings.nsf.template.length: "{seconds:.1f} seconds" settings.keybindings.title.window_title: "Keyboard shortcuts" settings.keybindings.title.application: "Application" settings.keybindings.title.sources: "Converter list" diff --git a/src/sampletones_config/layout/settings/nsf.yaml b/src/sampletones_config/layout/settings/nsf.yaml new file mode 100644 index 000000000..5f7df9c30 --- /dev/null +++ b/src/sampletones_config/layout/settings/nsf.yaml @@ -0,0 +1,5 @@ +window: + width: 540 + height: 0 +text_width: -64 +frame_width: 40 diff --git a/src/sampletones_core/constants/enums.py b/src/sampletones_core/constants/enums.py index a8afde97a..cb1006f41 100644 --- a/src/sampletones_core/constants/enums.py +++ b/src/sampletones_core/constants/enums.py @@ -89,6 +89,8 @@ class CQTWindow(StrEnum): RECTANGULAR = "rectangular" +ALL_CHANNELS: Final[FrozenSet[ChannelName]] = frozenset(ChannelName.items()) + TONE_CHANNELS: Final[FrozenSet[ChannelName]] = frozenset( { ChannelName.PULSE1, diff --git a/src/sampletones_core/performance/song.py b/src/sampletones_core/performance/song.py index d1ba39fa1..0c050c620 100644 --- a/src/sampletones_core/performance/song.py +++ b/src/sampletones_core/performance/song.py @@ -43,8 +43,9 @@ def song_instructions( OperationCanceled: If ``report`` withdraws the walk. """ song = project.song - groove = SongTiming.from_project(project).groove() - total = groove.total_ticks * song.order_length() + timing = SongTiming.from_project(project) + groove = timing.groove() + total = timing.frame_tick(song.order_length()) performances = {channel_name: ChannelPerformance() for channel_name in ChannelName.items()} streams: Dict[ChannelName, List[InstructionUnion]] = {channel_name: [] for channel_name in ChannelName.items()} diff --git a/src/sampletones_core/timing/song.py b/src/sampletones_core/timing/song.py index fa3250c18..b21cfc933 100644 --- a/src/sampletones_core/timing/song.py +++ b/src/sampletones_core/timing/song.py @@ -44,3 +44,18 @@ def groove(self) -> Groove: minimum_ticks=MIN_TICKS_PER_ROW, maximum_ticks=MAX_TICKS_PER_ROW, ) + + def frame_tick(self, frame: int) -> int: + """The engine tick order frame ``frame`` starts on. + + Every pattern holds the song's row count, so each frame the order plays lasts one groove + and a frame starts once that many grooves have played. The frame one past the order's last + is where the song ends, so its tick is the length of the whole song. + + Args: + frame: The order position, counted from 0. + + Returns: + int: The ticks the order plays before ``frame`` begins. + """ + return self.groove().total_ticks * frame diff --git a/src/sampletones_player/builder.py b/src/sampletones_player/builder.py index 4dbcf3743..39d9c2581 100644 --- a/src/sampletones_player/builder.py +++ b/src/sampletones_player/builder.py @@ -1,4 +1,4 @@ -from typing import Dict, Final, Mapping, Optional, Sequence, Tuple +from typing import AbstractSet, Dict, Final, Mapping, Optional, Sequence, Tuple from sampletones_core.constants.enums import ChannelName from sampletones_core.exporters.maps import CHANNEL_TO_EXPORTER_MAP @@ -16,6 +16,7 @@ from sampletones_player.compression.dictionary.phrase import Phrase from sampletones_player.compression.pitch import PitchTable from sampletones_player.compression.progress.report import CodecReporter +from sampletones_player.compression.scheme import CompressionScheme from sampletones_player.compression.seeds import phrases_from_project from sampletones_player.registers.channel import channel_registers from sampletones_player.registers.streams import ChannelStreams @@ -55,7 +56,9 @@ def streams_from_instructions( def song_from_reconstruction( reconstruction: Reconstruction, + *, loop_tick: Optional[int], + scheme: CompressionScheme, report: CodecReporter = silent_reporter, ) -> Song: """Builds the song the console plays a reconstruction as. @@ -70,6 +73,7 @@ def song_from_reconstruction( Args: reconstruction: The reconstruction to play. loop_tick: The tick the song returns to once it ends, or ``None`` where it stops there. + scheme: The layers of the codec the song is written with. report: Hears what the codec holds each time it looks up, and answers whether the compression goes on. @@ -91,6 +95,7 @@ def song_from_reconstruction( schedule=PlaySchedule.from_parameters(reconstruction.config.nes_frequency), loop_tick=loop_tick, seeds=NO_SEEDS, + scheme=scheme, report=report, ) @@ -149,6 +154,10 @@ def _repeats(instrument: InstrumentExport) -> bool: def song_from_sample( request: SampleExport, + *, + channels: AbstractSet[ChannelName], + loop_tick: Optional[int], + scheme: CompressionScheme, report: CodecReporter = silent_reporter, ) -> Song: """Builds the song the console plays an export request as. @@ -158,10 +167,14 @@ def song_from_sample( envelopes advance at, which the driver re-clocks to the rate the console calls it at. The request sounds each of its slices once, so the search fills the dictionary from what the - streams themselves repeat. + streams themselves repeat. The slices on the channels the song sounds are the ones played, and + every other channel rests throughout. Args: request: The slices to play together. + channels: The channels the song sounds. + loop_tick: The tick the song returns to once it ends, or ``None`` where it stops there. + scheme: The layers of the codec the song is written with. report: Hears what the codec holds each time it looks up, and answers whether the compression goes on. @@ -171,24 +184,31 @@ def song_from_sample( Raises: OperationCanceled: If ``report`` withdraws the compression. TypeError: If a channel's stream holds an instruction another channel sounds. - ValueError: If two slices name the same channel. + ValueError: If two slices name the same channel, or ``loop_tick`` lies outside the song's + ticks. """ return Song.from_streams( streams=streams_from_instructions( - instructions_from_instruments(request.instruments), + instructions_from_instruments( + [instrument for instrument in request.instruments if instrument.channel in channels] + ), get_timer_table(request.tuning), ), pitches=PitchTable.from_tuning(request.tuning), schedule=PlaySchedule.from_parameters(request.nes_frequency), - loop_tick=loop_tick_from_instruments(request.instruments), + loop_tick=loop_tick, seeds=NO_SEEDS, + scheme=scheme, report=report, ) def song_from_project( project: Project, + *, + channels: AbstractSet[ChannelName], loop_tick: Optional[int], + scheme: CompressionScheme, report: CodecReporter = silent_reporter, walk: WalkReporter = silent_reporter, ) -> Song: @@ -201,11 +221,14 @@ def song_from_project( holds, the tuning its pitches become timers under. A row plays a sample the project already holds, so the samples themselves seed the dictionary - and every row naming one reaches the stream as a token naming that entry. + and every row naming one reaches the stream as a token naming that entry. The channels the song + sounds are the ones that play and seed it, and every other channel rests throughout. Args: project: The project whose song is played. + channels: The channels the song sounds. loop_tick: The tick the song returns to once it ends, or ``None`` where it stops there. + scheme: The layers of the codec the song is written with. report: Hears what the codec holds each time it looks up, and answers whether the compression goes on. walk: Hears how far the song has been played out, and answers whether the walk goes on. @@ -220,14 +243,16 @@ def song_from_project( reconstructed against tunings that differ. """ tuning = tuning_from_project(project) + instructions = song_instructions(project, walk) return Song.from_streams( streams=streams_from_instructions( - song_instructions(project, walk), + {channel: stream for channel, stream in instructions.items() if channel in channels}, get_timer_table(tuning), ), pitches=PitchTable.from_tuning(tuning), schedule=PlaySchedule.from_parameters(project.settings.nes_frequency), loop_tick=loop_tick, - seeds=phrases_from_project(project, tuning), + seeds=phrases_from_project(project, tuning, channels), + scheme=scheme, report=report, ) diff --git a/src/sampletones_player/compression/scheme.py b/src/sampletones_player/compression/scheme.py new file mode 100644 index 000000000..28d3814f0 --- /dev/null +++ b/src/sampletones_player/compression/scheme.py @@ -0,0 +1,51 @@ +from enum import StrEnum +from typing import Tuple + +from sampletones_player.compression.options import EVERY_LAYER, CodecOptions + + +class CompressionScheme(StrEnum): + """How hard a song is compressed, stated as the layers of the codec it is written with. + + Each scheme keeps the layers of the one before it and adds its own, so the choice runs from a + song spelled out value by value to the smallest encoding the codec reaches, and the time an + export spends grows along the way. Every scheme writes a song the driver plays. + """ + + NONE = "none" + RUNS = "runs" + INSTRUMENTS = "instruments" + SEARCH = "search" + + @property + def options(self) -> CodecOptions: + """The layers a song compressed under this scheme is written with. + + ``NONE`` spells every value out as literals, ``RUNS`` writes a held value once, + ``INSTRUMENTS`` names the phrases the instruments seed at whatever pitch they are played + at, and ``SEARCH`` adds the phrases the song's own planes repeat. + """ + match self: + case CompressionScheme.NONE: + return CodecOptions(holds=False, phrases=False, transposition=False, search=False) + case CompressionScheme.RUNS: + return CodecOptions(holds=True, phrases=False, transposition=False, search=False) + case CompressionScheme.INSTRUMENTS: + return CodecOptions(holds=True, phrases=True, transposition=True, search=False) + case CompressionScheme.SEARCH: + return EVERY_LAYER + + +def offered_schemes(seeded: bool) -> Tuple[CompressionScheme, ...]: + """The schemes worth choosing between for a song, in the order they compress harder. + + ``INSTRUMENTS`` names only the phrases a song's instruments seed, so a song seeding none reads + the same under it as under ``RUNS`` and is offered the schemes that differ. + + Args: + seeded: Whether the song's instruments seed the dictionary. + + Returns: + Tuple[CompressionScheme, ...]: The schemes, from the lightest to the hardest. + """ + return tuple(scheme for scheme in CompressionScheme if seeded or scheme != CompressionScheme.INSTRUMENTS) diff --git a/src/sampletones_player/compression/seeds.py b/src/sampletones_player/compression/seeds.py index dbfe7bc55..665a64f0f 100644 --- a/src/sampletones_player/compression/seeds.py +++ b/src/sampletones_player/compression/seeds.py @@ -1,5 +1,6 @@ -from typing import List, Tuple +from typing import AbstractSet, List, Tuple +from sampletones_core.constants.enums import ChannelName from sampletones_core.exporters.maps import CHANNEL_TO_EXPORTER_MAP from sampletones_core.exporters.slices import iterate_voice_slices from sampletones_core.project.project import Project @@ -15,6 +16,7 @@ def phrases_from_project( project: Project, tuning: Tuning, + channels: AbstractSet[ChannelName], ) -> Tuple[Phrase, ...]: """The phrases a project's own instruments offer the dictionary. @@ -24,11 +26,13 @@ def phrases_from_project( row asks for. A plane holding one value throughout offers the dictionary nothing a hold covers more - cheaply, so the slices seed the planes that turn over. + cheaply, so the slices seed the planes that turn over. The rows of a song play the slices on + the channels it sounds, so those are the slices that seed it. Args: project: The project whose samples the song plays. tuning: Where concert pitch sits, which decides the timer each pitch sounds at. + channels: The channels the song sounds. Returns: Tuple[Phrase, ...]: The phrases, in instrument-table order. @@ -38,6 +42,9 @@ def phrases_from_project( phrases: List[Phrase] = [] for voice_slice in iterate_voice_slices(project): channel = voice_slice.channel + if channel not in channels: + continue + played = {channel: CHANNEL_TO_EXPORTER_MAP[channel].from_features(voice_slice.features)} planes = channel_planes( channel, diff --git a/src/sampletones_player/compression/song.py b/src/sampletones_player/compression/song.py index df0f34741..83913bf8a 100644 --- a/src/sampletones_player/compression/song.py +++ b/src/sampletones_player/compression/song.py @@ -1,15 +1,15 @@ from typing import FrozenSet, Optional, Sequence -from sampletones_player.compression.budget import DEFAULT_SEARCH_BUDGET, SearchBudget +from sampletones_player.compression.budget import DEFAULT_SEARCH_BUDGET from sampletones_player.compression.compressed import CompressedPlanes from sampletones_player.compression.decode import decode_planes from sampletones_player.compression.dictionary.phrase import Phrase from sampletones_player.compression.encode import encode_planes -from sampletones_player.compression.options import EVERY_LAYER from sampletones_player.compression.pitch import PitchTable from sampletones_player.compression.planes.rebuild import streams_from_planes from sampletones_player.compression.planes.separate import planes_from_streams from sampletones_player.compression.progress.report import CodecReporter +from sampletones_player.compression.scheme import CompressionScheme from sampletones_player.registers.streams import ChannelStreams from sampletones_shared.utils.progress import silent_reporter @@ -26,8 +26,8 @@ def compress_song( pitches: PitchTable, *, seeds: Sequence[Phrase], - loop_tick: Optional[int] = None, - budget: SearchBudget = DEFAULT_SEARCH_BUDGET, + loop_tick: Optional[int], + scheme: CompressionScheme, report: CodecReporter = silent_reporter, ) -> CompressedPlanes: """Compresses a song's four register streams into the dictionary and streams a file carries. @@ -41,7 +41,7 @@ def compress_song( pitches: The timer each pitch sounds at, which is what turns a timer into an index. seeds: The phrases the song's instruments offer the dictionary. loop_tick: The tick the song returns to once it ends, or ``None`` where it stops there. - budget: How much work the search spends beyond the phrases the instruments seed. + scheme: The layers of the codec the song is written with. report: Hears what the codec holds each time it looks up, and answers whether it goes on. Returns: @@ -54,9 +54,9 @@ def compress_song( return encode_planes( planes_from_streams(streams, pitches), seeds, - options=EVERY_LAYER, + options=scheme.options, boundaries=_entries(loop_tick), - budget=budget, + budget=DEFAULT_SEARCH_BUDGET, report=report, ) diff --git a/src/sampletones_player/export.py b/src/sampletones_player/export.py deleted file mode 100644 index e2532a767..000000000 --- a/src/sampletones_player/export.py +++ /dev/null @@ -1,228 +0,0 @@ -from pathlib import Path -from typing import Final, FrozenSet - -from sampletones_core.exports.artifact import ExportArtifact -from sampletones_core.exports.format import ExportFormat -from sampletones_core.exports.progress import ( - ExportProgress, - ExportReporter, - announce, -) -from sampletones_core.exports.request import ( - InstrumentExport, - ProjectExport, - SampleExport, -) -from sampletones_core.exports.scope import ExportScope -from sampletones_core.exports.stage import ExportStage -from sampletones_core.performance import WalkProgress, WalkReporter -from sampletones_player.builder import SONG_START, song_from_project, song_from_sample -from sampletones_player.compression.progress.report import CodecProgress, CodecReporter -from sampletones_player.driver.image import DriverImage -from sampletones_player.nsf.file import write_nsf -from sampletones_player.nsf.information import NSFInformation -from sampletones_shared.paths.extensions import EXT_FILE_NSF -from sampletones_shared.utils.progress import silent_reporter - -SUPPORTED_SCOPES: FrozenSet[ExportScope] = frozenset( - { - ExportScope.INSTRUMENT, - ExportScope.SAMPLE, - ExportScope.PROJECT, - } -) - -NO_ARTIST: Final[str] = "" -WHOLE_ENVELOPE: None = None -NOTHING_DONE: Final[int] = 0 -ONE_FILE: Final[int] = 1 -UNMEASURED: None = None - - -def _walking(report: ExportReporter) -> WalkReporter: - """The walk's own reckoning, said in the words an export reports itself in. - - A song's order states the ticks it lasts before a row of it is played, so this stage travels - toward a length it knows and reads as a true fraction of the song. - - Args: - report: Hears each stage of the export, and answers whether it goes on. - - Returns: - WalkReporter: What playing the song out tells the export about itself. - """ - - def reached(progress: WalkProgress) -> bool: - return report( - ExportProgress( - stage=ExportStage.WALKING, - completed=progress.ticks, - total=progress.total, - ) - ) - - return reached - - -def _compressing(report: ExportReporter) -> CodecReporter: - """The codec's own reckoning, said in the words an export reports itself in. - - A codec run ends when no further phrase pays for itself, which the song decides rather than - the caller, so what it offers is the bytes it has laid down so far and no length to measure - them against. - - Args: - report: Hears each stage of the export, and answers whether it goes on. - - Returns: - CodecReporter: What the compression tells the export about itself. - """ - - def reached(progress: CodecProgress) -> bool: - return report( - ExportProgress( - stage=ExportStage.COMPRESSING, - completed=progress.size, - total=UNMEASURED, - ) - ) - - return reached - - -class NSFBackend: - """Writes the ``.nsf`` files NES sound players and the console itself play. - - An NSF carries its own driver, so the file plays the reconstruction rather than describing - it to a program that does: every channel slice sounds at once on the channel it was - reconstructed for, at the rate it was built at and in the tuning it was built with. One file - holds one song, so a reconstruction and a single slice each become a program of their own, - the slice sounding on its channel alone. - - The console's program area bounds how long a song may run, and one outgrowing it is reported - rather than written short. - - Every file carries the same assembled driver, which the backend reads once as it is built. - A build shipping without it therefore reports itself where the backends are composed, and - an export spends its reads on the song alone. - """ - - def __init__(self) -> None: - """Reads the driver every written file carries. - - Raises: - OSError: If the packaged driver is absent. - ValueError: If the packaged driver lays out something other than the addresses it - is built to answer at. - """ - self._image = DriverImage.load() - - @property - def export_format(self) -> ExportFormat: - return ExportFormat.NSF - - @property - def supported_scopes(self) -> FrozenSet[ExportScope]: - return SUPPORTED_SCOPES - - def extension(self, scope: ExportScope) -> str: # pylint: disable=unused-argument - return EXT_FILE_NSF - - def write_instrument( - self, - destination: Path, - request: InstrumentExport, - report: ExportReporter = silent_reporter, - ) -> ExportArtifact: - """Writes a program playing one channel slice. - - Raises: - OperationCanceled: If ``report`` withdraws the write. - SongTooLargeError: If the slice runs longer than the program area holds. - OSError: If the destination cannot be written. - """ - sample = SampleExport( - name=request.name, - instruments=(request,), - nes_frequency=request.nes_frequency, - tuning=request.tuning, - ) - return self.write_sample(destination, sample, report) - - def write_sample( - self, - destination: Path, - request: SampleExport, - report: ExportReporter = silent_reporter, - ) -> ExportArtifact: - """Writes a program playing every channel slice of one reconstruction together. - - The slices are sounded out tick by tick, the ticks are compressed to what the console - has room for, and the file is written; each of those says so as it starts, so a run of - seconds reads as the work it is doing. - - Raises: - OperationCanceled: If ``report`` withdraws the write. - SongTooLargeError: If the reconstruction runs longer than the program area holds. - OSError: If the destination cannot be written. - """ - destination.parent.mkdir(parents=True, exist_ok=True) - announce(report, ExportStage.WALKING, NOTHING_DONE, UNMEASURED) - song = song_from_sample(request, _compressing(report)) - - announce(report, ExportStage.WRITING, NOTHING_DONE, ONE_FILE) - write_nsf( - destination, - song, - NSFInformation( - title=request.name, - artist=NO_ARTIST, - ), - self._image, - ) - announce(report, ExportStage.WRITING, ONE_FILE, ONE_FILE) - - return ExportArtifact(paths=(destination,), truncation=WHOLE_ENVELOPE) - - def write_project( - self, - destination: Path, - request: ProjectExport, - report: ExportReporter = silent_reporter, - ) -> ExportArtifact: - """Writes a program playing a whole composition. - - The arrangement is played out row by row into the ticks each channel sounds, those ticks - are compressed to what the console has room for, and the file is written; each of those - says so as it starts, and the walk reads as a fraction of the song it is playing out. - - The file repeats from its first tick, which is how a piece of music is listened to and - what an NSF player expects of a song that has reached its end. - - Raises: - OperationCanceled: If ``report`` withdraws the write. - SongTooLargeError: If the song holds more than the program area has room for. - OSError: If the destination cannot be written. - ValueError: If the project's samples were reconstructed against tunings that differ. - """ - destination.parent.mkdir(parents=True, exist_ok=True) - song = song_from_project( - request.project, - SONG_START, - _compressing(report), - _walking(report), - ) - - announce(report, ExportStage.WRITING, NOTHING_DONE, ONE_FILE) - write_nsf( - destination, - song, - NSFInformation( - title=request.project.info.title, - artist=request.project.info.author, - ), - self._image, - ) - announce(report, ExportStage.WRITING, ONE_FILE, ONE_FILE) - - return ExportArtifact(paths=(destination,), truncation=WHOLE_ENVELOPE) diff --git a/src/sampletones_player/export/__init__.py b/src/sampletones_player/export/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_player/export/backend.py b/src/sampletones_player/export/backend.py new file mode 100644 index 000000000..8e997a595 --- /dev/null +++ b/src/sampletones_player/export/backend.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +from pathlib import Path +from typing import FrozenSet + +from sampletones_core.exports.artifact import ExportArtifact +from sampletones_core.exports.format import ExportFormat +from sampletones_core.exports.progress import ExportReporter +from sampletones_core.exports.request import ( + InstrumentExport, + ProjectExport, + SampleExport, +) +from sampletones_core.exports.scope import ExportScope +from sampletones_player.export.choice import ProgramChoice +from sampletones_player.export.chosen import ChosenProgram +from sampletones_player.export.program import NSFProgram +from sampletones_player.export.stated import StatedProgram +from sampletones_player.export.writer import NSFWriter +from sampletones_shared.paths.extensions import EXT_FILE_NSF +from sampletones_shared.utils.progress import silent_reporter + +SUPPORTED_SCOPES: FrozenSet[ExportScope] = frozenset( + { + ExportScope.INSTRUMENT, + ExportScope.SAMPLE, + ExportScope.PROJECT, + } +) + + +class NSFBackend: + """Writes the ``.nsf`` files NES sound players and the console itself play. + + An NSF carries its own driver, so the file plays the reconstruction rather than describing + it to a program that does: every channel slice sounds at once on the channel it was + reconstructed for, at the rate it was built at and in the tuning it was built with. One file + holds one song, so a reconstruction and a single slice each become a program of their own, + the slice sounding on its channel alone. + + What the program states beyond its song — the channels, the repeat, the compression and the + text — is a :class:`ProgramChoice`. The backend the application registers writes each request + as its source states; :meth:`choosing` answers with one writing the program a user settled. + + The console's program area bounds how long a song may run, and one outgrowing it is reported + rather than written short. + """ + + def __init__( + self, + writer: NSFWriter, + choice: ProgramChoice, + ) -> None: + self._writer = writer + self._choice = choice + + @classmethod + def stated(cls) -> NSFBackend: + """The backend writing each request as the program its own source states. + + The driver every file carries is read here, so a build shipping without it reports itself + where the backends are composed. + + Raises: + OSError: If the packaged driver is absent. + ValueError: If the packaged driver lays out something other than the addresses it + is built to answer at. + """ + return cls(NSFWriter.load(), StatedProgram()) + + def choosing(self, program: NSFProgram) -> NSFBackend: + """The backend writing every request as ``program``, over the driver this one holds. + + Args: + program: The choices the file carries. + + Returns: + NSFBackend: The backend to hand the run. + """ + return NSFBackend(self._writer, ChosenProgram(program)) + + @property + def export_format(self) -> ExportFormat: + return ExportFormat.NSF + + @property + def supported_scopes(self) -> FrozenSet[ExportScope]: + return SUPPORTED_SCOPES + + def extension(self, scope: ExportScope) -> str: # pylint: disable=unused-argument + return EXT_FILE_NSF + + def write_instrument( + self, + destination: Path, + request: InstrumentExport, + report: ExportReporter = silent_reporter, + ) -> ExportArtifact: + """Writes a program playing one channel slice. + + Raises: + OperationCanceled: If ``report`` withdraws the write. + SongTooLargeError: If the slice runs longer than the program area holds. + OSError: If the destination cannot be written. + """ + sample = SampleExport( + name=request.name, + instruments=(request,), + nes_frequency=request.nes_frequency, + tuning=request.tuning, + ) + return self.write_sample(destination, sample, report) + + def write_sample( + self, + destination: Path, + request: SampleExport, + report: ExportReporter = silent_reporter, + ) -> ExportArtifact: + """Writes a program playing every channel slice of one reconstruction together. + + Raises: + OperationCanceled: If ``report`` withdraws the write. + SongTooLargeError: If the reconstruction runs longer than the program area holds. + OSError: If the destination cannot be written. + ValueError: If the program's loop tick lies outside the song. + """ + return self._writer.write_sample( + destination, + request, + self._choice.for_sample(request), + report, + ) + + def write_project( + self, + destination: Path, + request: ProjectExport, + report: ExportReporter = silent_reporter, + ) -> ExportArtifact: + """Writes a program playing a whole composition. + + Raises: + OperationCanceled: If ``report`` withdraws the write. + SongTooLargeError: If the song holds more than the program area has room for. + OSError: If the destination cannot be written. + ValueError: If the program's loop tick lies outside the song, or the project's samples + were reconstructed against tunings that differ. + """ + return self._writer.write_project( + destination, + request.project, + self._choice.for_project(request.project), + report, + ) diff --git a/src/sampletones_player/export/choice.py b/src/sampletones_player/export/choice.py new file mode 100644 index 000000000..d49716c5c --- /dev/null +++ b/src/sampletones_player/export/choice.py @@ -0,0 +1,20 @@ +from typing import Protocol + +from sampletones_core.exports.request import SampleExport +from sampletones_core.project.project import Project +from sampletones_player.export.program import NSFProgram + + +class ProgramChoice(Protocol): + """Which program a request is written as. + + A request arriving through the export seam carries the work alone, so the program it is + written under is decided beside it: either the one each source states for itself, or one the + user settled before the run. + """ + + def for_sample(self, request: SampleExport) -> NSFProgram: + """The program a reconstruction's slices are written as.""" + + def for_project(self, project: Project) -> NSFProgram: + """The program a whole composition is written as.""" diff --git a/src/sampletones_player/export/chosen.py b/src/sampletones_player/export/chosen.py new file mode 100644 index 000000000..b4435917f --- /dev/null +++ b/src/sampletones_player/export/chosen.py @@ -0,0 +1,20 @@ +from sampletones_core.exports.request import SampleExport +from sampletones_core.project.project import Project +from sampletones_player.export.program import NSFProgram + + +class ChosenProgram: + """Writes a request as the one program the user settled before the run. + + Attributes: + program: The program the run writes. + """ + + def __init__(self, program: NSFProgram) -> None: + self.program = program + + def for_sample(self, request: SampleExport) -> NSFProgram: # pylint: disable=unused-argument + return self.program + + def for_project(self, project: Project) -> NSFProgram: # pylint: disable=unused-argument + return self.program diff --git a/src/sampletones_player/export/program.py b/src/sampletones_player/export/program.py new file mode 100644 index 000000000..a7ccf240d --- /dev/null +++ b/src/sampletones_player/export/program.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +from typing import Annotated, Final, FrozenSet, Optional + +from pydantic import BaseModel, ConfigDict, Field + +from sampletones_core.constants.enums import ALL_CHANNELS, ChannelName +from sampletones_core.exports.request import SampleExport +from sampletones_core.project.project import Project +from sampletones_player.builder import SONG_START, loop_tick_from_instruments +from sampletones_player.compression.scheme import CompressionScheme +from sampletones_player.nsf.information import NSFInformation +from sampletones_shared.application import SAMPLETONES_COPYRIGHT + +NO_ARTIST: Final[str] = "" +DEFAULT_SCHEME: Final[CompressionScheme] = CompressionScheme.SEARCH + + +class NSFProgram(BaseModel): + """Everything a written program states beyond the song it plays. + + A song reaches the console as the channels it sounds, the tick it returns to once it ends and + the layers it is compressed with, and the file lists it under three lines of text. Those are + the choices an export makes, and :meth:`for_project` and :meth:`for_sample` state the ones it + makes on its own. + + Attributes: + information: The text the file is listed under. + channels: The channels the program sounds, every other one resting throughout. + loop_tick: The tick the song returns to once it ends, or ``None`` where it stops there. + scheme: The layers of the codec the song is written with. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + information: NSFInformation + channels: FrozenSet[ChannelName] = Field(min_length=1) + loop_tick: Optional[Annotated[int, Field(ge=0)]] + scheme: CompressionScheme + + @classmethod + def for_project(cls, project: Project) -> NSFProgram: + """The program a whole composition is written as. + + The file is listed under the project's own title and author, sounds every channel, and + repeats from its first tick, which is how a piece of music is listened to and what an NSF + player expects of a song that has reached its end. + + Args: + project: The composition to write. + + Returns: + NSFProgram: The program, compressed as hard as the codec reaches. + """ + return cls( + information=NSFInformation( + title=project.info.title, + artist=project.info.author, + copyright=SAMPLETONES_COPYRIGHT, + ), + channels=ALL_CHANNELS, + loop_tick=SONG_START, + scheme=DEFAULT_SCHEME, + ) + + @classmethod + def for_sample(cls, request: SampleExport) -> NSFProgram: + """The program a reconstruction's slices are written as. + + The file is listed under the reconstruction's name and credited to nobody, sounds every + slice the request carries, and repeats where every slice repeats. + + Args: + request: The slices to write. + + Returns: + NSFProgram: The program, compressed as hard as the codec reaches. + """ + return cls( + information=NSFInformation( + title=request.name, + artist=NO_ARTIST, + copyright=SAMPLETONES_COPYRIGHT, + ), + channels=ALL_CHANNELS, + loop_tick=loop_tick_from_instruments(request.instruments), + scheme=DEFAULT_SCHEME, + ) diff --git a/src/sampletones_player/export/reports.py b/src/sampletones_player/export/reports.py new file mode 100644 index 000000000..0e3067a6c --- /dev/null +++ b/src/sampletones_player/export/reports.py @@ -0,0 +1,56 @@ +from sampletones_core.exports.progress import ExportProgress, ExportReporter +from sampletones_core.exports.stage import ExportStage +from sampletones_core.performance import WalkProgress, WalkReporter +from sampletones_player.compression.progress.report import CodecProgress, CodecReporter + +UNMEASURED: None = None + + +def walk_reporter(report: ExportReporter) -> WalkReporter: + """The walk's own reckoning, said in the words an export reports itself in. + + A song's order states the ticks it lasts before a row of it is played, so this stage travels + toward a length it knows and reads as a true fraction of the song. + + Args: + report: Hears each stage of the export, and answers whether it goes on. + + Returns: + WalkReporter: What playing the song out tells the export about itself. + """ + + def reached(progress: WalkProgress) -> bool: + return report( + ExportProgress( + stage=ExportStage.WALKING, + completed=progress.ticks, + total=progress.total, + ) + ) + + return reached + + +def codec_reporter(report: ExportReporter) -> CodecReporter: + """The codec's own reckoning, said in the words an export reports itself in. + + A codec run ends when no further phrase pays for itself, which the song decides, so what it + offers is the bytes it has laid down so far, measured against no length of its own. + + Args: + report: Hears each stage of the export, and answers whether it goes on. + + Returns: + CodecReporter: What the compression tells the export about itself. + """ + + def reached(progress: CodecProgress) -> bool: + return report( + ExportProgress( + stage=ExportStage.COMPRESSING, + completed=progress.size, + total=UNMEASURED, + ) + ) + + return reached diff --git a/src/sampletones_player/export/stated.py b/src/sampletones_player/export/stated.py new file mode 100644 index 000000000..7d50a3969 --- /dev/null +++ b/src/sampletones_player/export/stated.py @@ -0,0 +1,17 @@ +from sampletones_core.exports.request import SampleExport +from sampletones_core.project.project import Project +from sampletones_player.export.program import NSFProgram + + +class StatedProgram: + """Writes each request as the program its own source states. + + This is what an export asks for when nobody set one up: the text, channels, repeat and + compression :meth:`NSFProgram.for_sample` and :meth:`NSFProgram.for_project` state. + """ + + def for_sample(self, request: SampleExport) -> NSFProgram: + return NSFProgram.for_sample(request) + + def for_project(self, project: Project) -> NSFProgram: + return NSFProgram.for_project(project) diff --git a/src/sampletones_player/export/writer.py b/src/sampletones_player/export/writer.py new file mode 100644 index 000000000..c66c7669d --- /dev/null +++ b/src/sampletones_player/export/writer.py @@ -0,0 +1,130 @@ +from pathlib import Path +from typing import Final, Self + +from sampletones_core.exports.artifact import ExportArtifact +from sampletones_core.exports.progress import ExportReporter, announce +from sampletones_core.exports.request import SampleExport +from sampletones_core.exports.stage import ExportStage +from sampletones_core.project.project import Project +from sampletones_player.builder import song_from_project, song_from_sample +from sampletones_player.driver.image import DriverImage +from sampletones_player.export.program import NSFProgram +from sampletones_player.export.reports import UNMEASURED, codec_reporter, walk_reporter +from sampletones_player.nsf.file import write_nsf +from sampletones_player.song import Song + +WHOLE_ENVELOPE: None = None +NOTHING_DONE: Final[int] = 0 +ONE_FILE: Final[int] = 1 + + +class NSFWriter: + """Lays a program down on disk: its song played out, compressed, and written behind the driver. + + Each of those says so as it starts, so a run of seconds reads as the work it is doing, and the + program states every choice the file carries — the channels the song sounds, where it repeats, + how hard it is compressed and the text it is listed under. + + Every file carries the same assembled driver, which the writer holds from the moment it is + built, so a run spends its reads on the song alone. + """ + + def __init__(self, image: DriverImage) -> None: + self._image = image + + @classmethod + def load(cls) -> Self: + """Builds the writer over the driver the package ships. + + Raises: + OSError: If the packaged driver is absent. + ValueError: If the packaged driver lays out something other than the addresses it + is built to answer at. + """ + return cls(DriverImage.load()) + + def write_sample( + self, + destination: Path, + request: SampleExport, + program: NSFProgram, + report: ExportReporter, + ) -> ExportArtifact: + """Writes a program playing a reconstruction's slices together. + + Args: + destination: The file to write. + request: The slices to play. + program: The choices the file carries. + report: Hears each stage of the write, and answers whether it goes on. + + Returns: + ExportArtifact: The file written. + + Raises: + OperationCanceled: If ``report`` withdraws the write. + SongTooLargeError: If the song holds more than the program area has room for. + OSError: If the destination cannot be written. + ValueError: If the program's loop tick lies outside the song. + """ + destination.parent.mkdir(parents=True, exist_ok=True) + announce(report, ExportStage.WALKING, NOTHING_DONE, UNMEASURED) + song = song_from_sample( + request, + channels=program.channels, + loop_tick=program.loop_tick, + scheme=program.scheme, + report=codec_reporter(report), + ) + return self._write(destination, song, program, report) + + def write_project( + self, + destination: Path, + project: Project, + program: NSFProgram, + report: ExportReporter, + ) -> ExportArtifact: + """Writes a program playing a whole composition. + + The arrangement is played out row by row into the ticks each channel sounds, and the walk + reads as a fraction of the song it is playing out. + + Args: + destination: The file to write. + project: The composition to play. + program: The choices the file carries. + report: Hears each stage of the write, and answers whether it goes on. + + Returns: + ExportArtifact: The file written. + + Raises: + OperationCanceled: If ``report`` withdraws the write. + SongTooLargeError: If the song holds more than the program area has room for. + OSError: If the destination cannot be written. + ValueError: If the program's loop tick lies outside the song, or the project's samples + were reconstructed against tunings that differ. + """ + destination.parent.mkdir(parents=True, exist_ok=True) + song = song_from_project( + project, + channels=program.channels, + loop_tick=program.loop_tick, + scheme=program.scheme, + report=codec_reporter(report), + walk=walk_reporter(report), + ) + return self._write(destination, song, program, report) + + def _write( + self, + destination: Path, + song: Song, + program: NSFProgram, + report: ExportReporter, + ) -> ExportArtifact: + announce(report, ExportStage.WRITING, NOTHING_DONE, ONE_FILE) + write_nsf(destination, song, program.information, self._image) + announce(report, ExportStage.WRITING, ONE_FILE, ONE_FILE) + return ExportArtifact(paths=(destination,), truncation=WHOLE_ENVELOPE) diff --git a/src/sampletones_player/nsf/information.py b/src/sampletones_player/nsf/information.py index 782b8a909..e12ceede1 100644 --- a/src/sampletones_player/nsf/information.py +++ b/src/sampletones_player/nsf/information.py @@ -1,13 +1,39 @@ -from pydantic import BaseModel, ConfigDict +from typing import Final +from pydantic import BaseModel, ConfigDict, field_validator + +from sampletones_player.specification.nsf import STRING_TEXT_SIZE from sampletones_shared.application import SAMPLETONES_COPYRIGHT +TEXT_ENCODING: Final[str] = "utf-8" + + +def fit_field(text: str) -> str: + """The part of ``text`` a header string field holds. + + A field is a fixed run of bytes ending in a terminator, so its text is the longest run of + whole characters whose encoding fits in front of it. What a player lists is what this returns, + which lets whoever offers the text for editing show exactly what the file will carry. + + Args: + text: The text offered for the field. + + Returns: + str: ``text`` itself, or as many of its leading characters as the field holds. + """ + return text.encode(TEXT_ENCODING)[:STRING_TEXT_SIZE].decode(TEXT_ENCODING, errors="ignore") + + +def field_size(text: str) -> int: + """The bytes ``text`` takes in a header string field, ahead of its terminator.""" + return len(text.encode(TEXT_ENCODING)) + class NSFInformation(BaseModel): """The three text fields an NSF header carries, shown by the players that read them. - Each field reaches the file as a fixed 32-byte string, so text longer than the field holds - is written as much of itself as fits. + Each field reaches the file as a fixed 32-byte string ending in a terminator, so every text + is held as the part of itself the field has room for (:func:`fit_field`). Attributes: title: Name the song is listed under. @@ -20,3 +46,8 @@ class NSFInformation(BaseModel): title: str artist: str copyright: str = SAMPLETONES_COPYRIGHT + + @field_validator("title", "artist", "copyright") + @classmethod + def _fit_each_field(cls, text: str) -> str: + return fit_field(text) diff --git a/src/sampletones_player/song.py b/src/sampletones_player/song.py index 705872bcb..fbdac4840 100644 --- a/src/sampletones_player/song.py +++ b/src/sampletones_player/song.py @@ -10,6 +10,7 @@ from sampletones_player.compression.dictionary.phrase import Phrase from sampletones_player.compression.pitch import PitchTable from sampletones_player.compression.progress.report import CodecReporter +from sampletones_player.compression.scheme import CompressionScheme from sampletones_player.compression.song import compress_song, decompress_song from sampletones_player.registers.streams import ChannelStreams from sampletones_shared.utils.progress import silent_reporter @@ -46,6 +47,7 @@ def from_streams( schedule: PlaySchedule, loop_tick: Optional[int], seeds: Sequence[Phrase], + scheme: CompressionScheme, report: CodecReporter = silent_reporter, ) -> Song: """Compresses the register values a song plays into the song the console holds. @@ -57,6 +59,7 @@ def from_streams( loop_tick: The tick the song returns to once it ends, or ``None`` where it stops there. seeds: The phrases the song's instruments offer the dictionary. + scheme: The layers of the codec the song is written with. report: Hears what the codec holds each time it looks up, and answers whether the compression goes on. @@ -74,6 +77,7 @@ def from_streams( pitches, seeds=seeds, loop_tick=loop_tick, + scheme=scheme, report=report, ), pitches=pitches, diff --git a/src/sampletones_player/specification/nsf.py b/src/sampletones_player/specification/nsf.py index bc87e6198..5a3751a61 100644 --- a/src/sampletones_player/specification/nsf.py +++ b/src/sampletones_player/specification/nsf.py @@ -15,6 +15,8 @@ FIRST_SONG: Final[int] = 1 STRING_FIELD_SIZE: Final[int] = 32 +STRING_TERMINATOR_SIZE: Final[int] = 1 +STRING_TEXT_SIZE: Final[int] = STRING_FIELD_SIZE - STRING_TERMINATOR_SIZE BANKSWITCH_SIZE: Final[int] = 8 NSF2_LENGTH_SIZE: Final[int] = 3 diff --git a/src/sampletones_tools/codec/report/corpus.py b/src/sampletones_tools/codec/report/corpus.py index f220720a4..fb30d1f95 100644 --- a/src/sampletones_tools/codec/report/corpus.py +++ b/src/sampletones_tools/codec/report/corpus.py @@ -3,7 +3,7 @@ from random import Random from typing import Dict, Final, List, Tuple -from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.enums import ALL_CHANNELS, ChannelName from sampletones_core.constants.general import MAX_DUTY_CYCLE, MAX_PERIOD, MAX_VOLUME from sampletones_core.instructions import ( InstructionUnion, @@ -27,6 +27,7 @@ from sampletones_player.compression.pitch import PitchTable from sampletones_player.compression.planes.separate import planes_from_streams from sampletones_player.compression.planes.song import SongPlanes +from sampletones_player.compression.scheme import CompressionScheme from sampletones_player.compression.seeds import phrases_from_project from sampletones_player.song import Song from sampletones_shared.music import Tuning @@ -99,8 +100,12 @@ def sample_entries( entries.append( CorpusEntry( name=name, - song=song_from_reconstruction(sample.reconstruction, loop_tick=None), - seeds=phrases_from_project(_sample_project(sample, settings), tuning), + song=song_from_reconstruction( + sample.reconstruction, + loop_tick=None, + scheme=CompressionScheme.SEARCH, + ), + seeds=phrases_from_project(_sample_project(sample, settings), tuning, ALL_CHANNELS), tuning=tuning, ) ) @@ -124,8 +129,13 @@ def arrangement_entry( tuning = tuning_from_project(project) return CorpusEntry( name=name, - song=song_from_project(project, loop_tick=None), - seeds=phrases_from_project(project, tuning), + song=song_from_project( + project, + channels=ALL_CHANNELS, + loop_tick=None, + scheme=CompressionScheme.SEARCH, + ), + seeds=phrases_from_project(project, tuning, ALL_CHANNELS), tuning=tuning, ) @@ -219,6 +229,7 @@ def reconstruction_entry(name: str, seconds: int) -> CorpusEntry: schedule=PlaySchedule.from_parameters(RECONSTRUCTION_FREQUENCY), loop_tick=None, seeds=NO_SEEDS, + scheme=CompressionScheme.SEARCH, ), seeds=NO_SEEDS, tuning=tuning, diff --git a/src/sampletones_tools/codec/report/encoding.py b/src/sampletones_tools/codec/report/encoding.py index 414c27ba7..19a8db1fa 100644 --- a/src/sampletones_tools/codec/report/encoding.py +++ b/src/sampletones_tools/codec/report/encoding.py @@ -11,6 +11,7 @@ from sampletones_player.compression.options import CodecOptions from sampletones_player.compression.parse.plane import parse_plane from sampletones_player.compression.planes.song import SongPlanes +from sampletones_player.compression.scheme import CompressionScheme from sampletones_player.registers.streams import ChannelStreams from sampletones_player.specification.registers import DUTY_CYCLE_SHIFT from sampletones_tools.codec.report.corpus import CorpusEntry @@ -25,23 +26,10 @@ REGISTER_PLANES: Final[str] = "register planes" SPLIT_CONTROL: Final[str] = "split control" CONTROL_LEVEL_MASK: Final[int] = 0x3F -HOLDS_OPTIONS: Final[CodecOptions] = CodecOptions( - holds=True, - phrases=False, - transposition=False, - search=False, -) +HOLDS_OPTIONS: Final[CodecOptions] = CompressionScheme.RUNS.options PLANE_VARIANTS: Final[Tuple[Tuple[str, CodecOptions], ...]] = ( - ( - LITERALS, - CodecOptions( - holds=False, - phrases=False, - transposition=False, - search=False, - ), - ), + (LITERALS, CompressionScheme.NONE.options), (HOLDS, HOLDS_OPTIONS), ( INSTRUMENTS, @@ -52,24 +40,8 @@ search=False, ), ), - ( - TRANSPOSITION, - CodecOptions( - holds=True, - phrases=True, - transposition=True, - search=False, - ), - ), - ( - SEARCH, - CodecOptions( - holds=True, - phrases=True, - transposition=True, - search=True, - ), - ), + (TRANSPOSITION, CompressionScheme.INSTRUMENTS.options), + (SEARCH, CompressionScheme.SEARCH.options), ) diff --git a/src/sampletones_tools/codec/study/corpus/projects.py b/src/sampletones_tools/codec/study/corpus/projects.py index 9897231eb..0850b6679 100644 --- a/src/sampletones_tools/codec/study/corpus/projects.py +++ b/src/sampletones_tools/codec/study/corpus/projects.py @@ -1,6 +1,7 @@ from math import ceil from pathlib import Path +from sampletones_core.constants.enums import ALL_CHANNELS from sampletones_core.performance import song_instructions from sampletones_core.project.container import ProjectContainer from sampletones_core.project.project import Project @@ -69,7 +70,7 @@ def _song( group=group, source=source, planes=planes_from_streams(streams, pitches), - seeds=phrases_from_project(project, tuning), + seeds=phrases_from_project(project, tuning, ALL_CHANNELS), pitches=pitches, ) diff --git a/src/sampletones_tools/samples/nsf.py b/src/sampletones_tools/samples/nsf.py index ca577b01c..834879c13 100644 --- a/src/sampletones_tools/samples/nsf.py +++ b/src/sampletones_tools/samples/nsf.py @@ -3,8 +3,9 @@ from sampletones_core.exports.request import ProjectExport from sampletones_player.builder import song_from_reconstruction +from sampletones_player.compression.scheme import CompressionScheme from sampletones_player.driver.image import DriverImage -from sampletones_player.export import NSFBackend +from sampletones_player.export.backend import NSFBackend from sampletones_player.nsf.file import write_nsf from sampletones_player.nsf.information import NSFInformation from sampletones_shared.paths.extensions import EXT_FILE_NSF @@ -34,14 +35,18 @@ def write_samples(corpus: Corpus, output: Path) -> List[Path]: destination = output / f"{name}{EXT_FILE_NSF}" write_nsf( destination, - song_from_reconstruction(sample.reconstruction, loop_tick=None), + song_from_reconstruction( + sample.reconstruction, + loop_tick=None, + scheme=CompressionScheme.SEARCH, + ), exported_information(name, corpus.project.info.author), image, ) written.append(destination) arrangement = output / f"{SONG_NAME}{EXT_FILE_NSF}" - NSFBackend().write_project( + NSFBackend.stated().write_project( arrangement, ProjectExport(project=corpus.project), ) diff --git a/tests/integration/nsf/conftest.py b/tests/integration/nsf/conftest.py index 9c166fd90..b460fc303 100644 --- a/tests/integration/nsf/conftest.py +++ b/tests/integration/nsf/conftest.py @@ -5,6 +5,7 @@ from sampletones_core.project.voices.sample import Sample from sampletones_player.builder import song_from_reconstruction +from sampletones_player.compression.scheme import CompressionScheme from sampletones_player.driver.image import DriverImage from sampletones_player.song import Song from sampletones_shared.paths.extensions import EXT_FILE_NSF @@ -33,4 +34,4 @@ def sample(instrument_catalog: Dict[str, Sample]) -> Sample: @pytest.fixture def song(sample: Sample) -> Song: """The song the console plays that sample as.""" - return song_from_reconstruction(sample.reconstruction, loop_tick=None) + return song_from_reconstruction(sample.reconstruction, loop_tick=None, scheme=CompressionScheme.SEARCH) diff --git a/tests/integration/nsf/test_backend.py b/tests/integration/nsf/test_backend.py index 6f516b180..c0c0c11d1 100644 --- a/tests/integration/nsf/test_backend.py +++ b/tests/integration/nsf/test_backend.py @@ -5,7 +5,7 @@ import pytest from sampletones_core.audio.mixing import mix -from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.enums import ALL_CHANNELS, ChannelName from sampletones_core.exporters.naming import instrument_slice_name from sampletones_core.exports.request import ( InstrumentExport, @@ -19,13 +19,16 @@ from sampletones_core.project.tuning import tuning_from_project from sampletones_core.project.voices.sample import Sample from sampletones_core.timers.utils import get_timer_table +from sampletones_core.timing import SongTiming from sampletones_player.builder import ( SONG_START, instructions_from_instruments, song_from_project, song_from_sample, ) -from sampletones_player.export import NSFBackend +from sampletones_player.compression.scheme import CompressionScheme +from sampletones_player.export.backend import NSFBackend +from sampletones_player.export.program import NSFProgram from sampletones_player.registers.playable import playable from sampletones_player.song import Song from sampletones_player.specification.nsf import NSF_MAGIC @@ -40,6 +43,7 @@ ChannelInstructions = Dict[ChannelName, List[InstructionUnion]] PROJECT_ARTIFACT: Final[str] = "song" +LOOP_FRAME: Final[int] = 1 def resting(instruction: InstructionUnion) -> InstructionUnion: @@ -84,7 +88,7 @@ def sample_request(sample: Sample) -> SampleExport: @pytest.fixture(scope="module") def backend() -> NSFBackend: - return NSFBackend() + return NSFBackend.stated() @pytest.fixture(scope="module") @@ -119,7 +123,13 @@ def played( """What the console sounds when it plays each written file, read back out of its register writes.""" played: Dict[str, ChannelInstructions] = {} for name, destination in exported.items(): - song = song_from_sample(requests[name]) + program = NSFProgram.for_sample(requests[name]) + song = song_from_sample( + requests[name], + channels=program.channels, + loop_tick=program.loop_tick, + scheme=program.scheme, + ) trace = captured_file_trace(destination.read_bytes(), song) played[name] = instructions_from_trace( trace, @@ -196,7 +206,12 @@ def test_the_console_sounds_the_reconstructions_own_waveform( @pytest.fixture(scope="module") def project_song(integration_project: Project) -> Song: """The song the console plays the integration project's arrangement as.""" - return song_from_project(integration_project, SONG_START) + return song_from_project( + integration_project, + channels=ALL_CHANNELS, + loop_tick=SONG_START, + scheme=CompressionScheme.SEARCH, + ) @pytest.fixture(scope="module") @@ -257,3 +272,88 @@ def test_the_song_comes_round_rather_than_falling_silent( assert [resting(instruction) for instruction in sounded[ticks : 2 * ticks]] == [ resting(instruction) for instruction in sounded[:ticks] ] + + +def chosen_file( + backend: NSFBackend, + project: Project, + program: NSFProgram, + directory: Path, +) -> Path: + """The integration project written as ``program`` through the backend the application registers.""" + destination = directory / f"{PROJECT_ARTIFACT}{EXT_FILE_NSF}" + backend.choosing(program).write_project(destination, ProjectExport(project=project)) + return destination + + +def sounded_program( + data: bytes, + project_song: Song, + project: Project, + ticks: int, +) -> ChannelInstructions: + """What the console sounds over ``ticks`` of a written program, read back as instructions.""" + trace = captured_run(data, play_calls_reaching(project_song, ticks)) + return instructions_from_trace(trace, get_timer_table(tuning_from_project(project))) + + +class TestTheConsoleSoundsTheChosenProgram: + """A program the user settled reaches the APU as it was settled.""" + + def test_a_channel_left_out_rests_while_the_others_play_the_arrangement( + self, + backend: NSFBackend, + integration_project: Project, + project_song: Song, + tmp_path: Path, + ) -> None: + program = NSFProgram.for_project(integration_project).model_copy( + update={"channels": ALL_CHANNELS - {ChannelName.NOISE}} + ) + data = chosen_file(backend, integration_project, program, tmp_path).read_bytes() + played = sounded_program(data, project_song, integration_project, project_song.ticks) + + for channel, instructions in song_instructions(integration_project).items(): + sounded = played[channel][: len(instructions)] + if channel == ChannelName.NOISE: + assert not any(instruction.on for instruction in sounded) + else: + assert [resting(instruction) for instruction in sounded] == [ + resting(playable(instruction)) for instruction in instructions + ] + + def test_a_song_written_without_compression_sounds_the_arrangement( + self, + backend: NSFBackend, + integration_project: Project, + project_song: Song, + tmp_path: Path, + ) -> None: + program = NSFProgram.for_project(integration_project).model_copy(update={"scheme": CompressionScheme.NONE}) + data = chosen_file(backend, integration_project, program, tmp_path).read_bytes() + played = sounded_program(data, project_song, integration_project, project_song.ticks) + + for channel, instructions in song_instructions(integration_project).items(): + assert [resting(instruction) for instruction in played[channel][: len(instructions)]] == [ + resting(playable(instruction)) for instruction in instructions + ] + + def test_a_song_repeating_from_a_frame_comes_round_to_that_frame( + self, + backend: NSFBackend, + integration_project: Project, + project_song: Song, + tmp_path: Path, + ) -> None: + """The calls past the arrangement's end sound the frame the program returns to, onward.""" + loop_tick = SongTiming.from_project(integration_project).frame_tick(LOOP_FRAME) + program = NSFProgram.for_project(integration_project).model_copy(update={"loop_tick": loop_tick}) + data = chosen_file(backend, integration_project, program, tmp_path).read_bytes() + ticks = project_song.ticks + played = sounded_program(data, project_song, integration_project, 2 * ticks) + + repeated = ticks - loop_tick + for sounded in played.values(): + assert [resting(instruction) for instruction in sounded[ticks : ticks + repeated]] == [ + resting(instruction) for instruction in sounded[loop_tick:ticks] + ] diff --git a/tests/integration/nsf/test_driver_audio.py b/tests/integration/nsf/test_driver_audio.py index 14efacc1c..62fe41c6d 100644 --- a/tests/integration/nsf/test_driver_audio.py +++ b/tests/integration/nsf/test_driver_audio.py @@ -10,6 +10,7 @@ from sampletones_core.project.voices.sample import Sample from sampletones_core.timers.utils import get_timer_table from sampletones_player.builder import song_from_reconstruction +from sampletones_player.compression.scheme import CompressionScheme from sampletones_player.registers.playable import playable from tests.integration.nsf.console.instructions import instructions_from_trace, sounded_approximation from tests.integration.nsf.console.session import captured_trace @@ -20,7 +21,7 @@ def played_by_console(sample: Sample) -> ChannelInstructions: """The per-tick instructions the console sounds, read back out of the registers it wrote.""" - song = song_from_reconstruction(sample.reconstruction, loop_tick=None) + song = song_from_reconstruction(sample.reconstruction, loop_tick=None, scheme=CompressionScheme.SEARCH) trace = captured_trace(song, sample_information(sample.name)) return instructions_from_trace(trace, get_timer_table(sample.reconstruction.config.tuning)) diff --git a/tests/integration/nsf/test_driver_trace.py b/tests/integration/nsf/test_driver_trace.py index abf98d12e..34db3dd8b 100644 --- a/tests/integration/nsf/test_driver_trace.py +++ b/tests/integration/nsf/test_driver_trace.py @@ -5,6 +5,7 @@ from sampletones_core.project.voices.sample import Sample from sampletones_player.builder import song_from_reconstruction +from sampletones_player.compression.scheme import CompressionScheme from sampletones_player.driver.image import DriverImage from sampletones_player.nsf.song import song_to_bytes from sampletones_player.song import Song @@ -99,7 +100,7 @@ def test_the_driver_writes_what_the_model_states( sample: Sample, ) -> None: reclocked = sample.reconstruction.with_nes_frequency(test_case.expected) - song = song_from_reconstruction(reclocked, loop_tick=None) + song = song_from_reconstruction(reclocked, loop_tick=None, scheme=CompressionScheme.SEARCH) trace = captured_trace(song, sample_information(sample.name)) assert trace == RegisterTrace.from_song(song, play_calls_covering(song)) @@ -116,7 +117,9 @@ def test_the_rate_reaches_the_console_as_the_step_alone( available = PROGRAM_SIZE - len(driver_image.code) block = song_to_bytes(song, available) - reclocked_block = song_to_bytes(song_from_reconstruction(reclocked, loop_tick=None), available) + reclocked_block = song_to_bytes( + song_from_reconstruction(reclocked, loop_tick=None, scheme=CompressionScheme.SEARCH), available + ) assert block[STEP_FRACTION_OFFSET + WORD_SIZE :] == reclocked_block[STEP_FRACTION_OFFSET + WORD_SIZE :] assert block[:STEP_WHOLE_OFFSET] == reclocked_block[:STEP_WHOLE_OFFSET] @@ -151,9 +154,9 @@ def label(self) -> str: @staticmethod def repeating(sample: Sample, test_case: "TestCase") -> Song: reclocked = sample.reconstruction.with_nes_frequency(test_case.nes_frequency) - ticks = song_from_reconstruction(reclocked, loop_tick=None).ticks + ticks = song_from_reconstruction(reclocked, loop_tick=None, scheme=CompressionScheme.SEARCH).ticks loop_tick = ticks - test_case.remaining if test_case.remaining else ticks // 2 - return song_from_reconstruction(reclocked, loop_tick=loop_tick) + return song_from_reconstruction(reclocked, loop_tick=loop_tick, scheme=CompressionScheme.SEARCH) @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label) def test_the_driver_writes_what_the_model_states( diff --git a/tests/integration/nsf/test_nsf_pipeline.py b/tests/integration/nsf/test_nsf_pipeline.py index 98d739f48..e9f27c98c 100644 --- a/tests/integration/nsf/test_nsf_pipeline.py +++ b/tests/integration/nsf/test_nsf_pipeline.py @@ -8,6 +8,7 @@ from sampletones_core.project.voices.sample import Sample from sampletones_core.reconstructions import Reconstruction from sampletones_player.builder import song_from_reconstruction +from sampletones_player.compression.scheme import CompressionScheme from sampletones_player.driver.image import DriverImage from sampletones_player.nsf.file import write_nsf from sampletones_player.nsf.song import song_to_bytes @@ -66,7 +67,7 @@ def test_every_sample_reaches_a_playable_file( driver_image: DriverImage, ) -> None: for name, sample in instrument_catalog.items(): - song = song_from_reconstruction(sample.reconstruction, loop_tick=None) + song = song_from_reconstruction(sample.reconstruction, loop_tick=None, scheme=CompressionScheme.SEARCH) write_nsf(nsf_paths[name], song, sample_information(name), driver_image) assert nsf_paths[name].read_bytes()[: len(NSF_MAGIC)] == NSF_MAGIC @@ -146,7 +147,11 @@ def test_the_round_trip_leaves_the_exported_bytes_alone( ) -> None: stored = tmp_path / f"{sample.name}{EXT_FILE_RECONSTRUCTION}" sample.reconstruction.save(stored) - reloaded = song_from_reconstruction(Reconstruction.load(stored), loop_tick=None) + reloaded = song_from_reconstruction( + Reconstruction.load(stored), + loop_tick=None, + scheme=CompressionScheme.SEARCH, + ) information = sample_information(sample.name) before = tmp_path / "before.nsf" diff --git a/tests/integration/nsf/test_song_export.py b/tests/integration/nsf/test_song_export.py index 09d4e78d1..d6cf2f89a 100644 --- a/tests/integration/nsf/test_song_export.py +++ b/tests/integration/nsf/test_song_export.py @@ -2,9 +2,11 @@ import pytest +from sampletones_core.constants.enums import ALL_CHANNELS from sampletones_core.project.project import Project from sampletones_core.timing import SongTiming from sampletones_player.builder import song_from_project +from sampletones_player.compression.scheme import CompressionScheme from sampletones_player.driver.image import DriverImage from sampletones_player.nsf.song import song_to_bytes from sampletones_player.song import Song @@ -23,7 +25,12 @@ def read_word(data: bytes, offset: int) -> int: @pytest.fixture def project_song(integration_project: Project) -> Song: """The song the console plays the integration project's arrangement as.""" - return song_from_project(integration_project, loop_tick=None) + return song_from_project( + integration_project, + channels=ALL_CHANNELS, + loop_tick=None, + scheme=CompressionScheme.SEARCH, + ) class TestTheProjectReachesTheConsole: diff --git a/tests/integration/sampletones_application/services/test_export.py b/tests/integration/sampletones_application/services/test_export.py index ffd7856a5..32f941dc2 100644 --- a/tests/integration/sampletones_application/services/test_export.py +++ b/tests/integration/sampletones_application/services/test_export.py @@ -20,7 +20,7 @@ from sampletones_core.exports.stage import ExportStage from sampletones_core.project.project import Project from sampletones_core.timing import SongTiming -from sampletones_player.export import NSFBackend +from sampletones_player.export.backend import NSFBackend from sampletones_player.specification.nsf import NSF_MAGIC, PROGRAM_SIZE from sampletones_shared.music import Tuning from tests.suite.performance import ( @@ -48,7 +48,7 @@ def backend_fixture() -> FamiTrackerBackend: @pytest.fixture(name="console_backend") def console_backend_fixture() -> NSFBackend: - return NSFBackend() + return NSFBackend.stated() def overlong_features(initial_pitch: int) -> Features: diff --git a/tests/suite/nsf.py b/tests/suite/nsf.py new file mode 100644 index 000000000..db64b708b --- /dev/null +++ b/tests/suite/nsf.py @@ -0,0 +1,110 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Final, List, Tuple +from unittest.mock import MagicMock + +from sampletones_application.view_model.shared.nsf.choices import NSFExportChoices +from sampletones_application.view_model.shared.nsf.offer import FIRST_FRAME, NO_FRAMES, NSFExportOffer +from sampletones_application.view_model.shared.nsf.repeat import NSFRepeat +from sampletones_core.constants.enums import ChannelName +from sampletones_core.exports.backend import ExportBackend +from sampletones_core.exports.request import ProjectExport, SampleExport +from sampletones_core.exports.scope import ExportScope +from sampletones_player.compression.scheme import offered_schemes +from sampletones_player.export.program import NSFProgram +from sampletones_player.nsf.information import NSFInformation +from sampletones_shared.paths.extensions import EXT_FILE_NSF + +ORDER_FRAMES: Final[int] = 12 +SAMPLE_CHANNELS: Final[Tuple[ChannelName, ...]] = (ChannelName.PULSE1, ChannelName.TRIANGLE) +PROGRAM_TITLE: Final[str] = "Rainy Day Theme" +PROGRAM_ARTIST: Final[str] = "J. Kowalski" + + +def project_offer() -> NSFExportOffer: + """What a project laid out in a dozen frames offers: every channel, frame and scheme.""" + return NSFExportOffer( + channels=tuple(ChannelName.items()), + frame_count=ORDER_FRAMES, + schemes=offered_schemes(seeded=True), + ) + + +def sample_offer() -> NSFExportOffer: + """What a reconstruction sounding two channels offers.""" + return NSFExportOffer( + channels=SAMPLE_CHANNELS, + frame_count=NO_FRAMES, + schemes=offered_schemes(seeded=False), + ) + + +def standing_choices(offer: NSFExportOffer) -> NSFExportChoices: + """Choices sounding every channel ``offer`` holds and repeating from the start.""" + return NSFExportChoices( + information=NSFInformation(title=PROGRAM_TITLE, artist=PROGRAM_ARTIST), + channels=frozenset(offer.channels), + repeat=NSFRepeat.FROM_START, + loop_frame=FIRST_FRAME, + scheme=offer.schemes[-1], + ) + + +@dataclass(frozen=True) +class ProjectRun: + destination: Path + backend: ExportBackend + request: ProjectExport + + +@dataclass(frozen=True) +class SampleRun: + destination: Path + backend: ExportBackend + request: SampleExport + + +class FakeNSFExportService: + """The export service as an NSF setup hands its run over, holding each run it was given.""" + + def __init__(self) -> None: + self.projects: List[ProjectRun] = [] + self.samples: List[SampleRun] = [] + + def export_project( + self, + destination: Path, + backend: ExportBackend, + request: ProjectExport, + ) -> None: + self.projects.append(ProjectRun(destination=destination, backend=backend, request=request)) + + def export_sample( + self, + destination: Path, + backend: ExportBackend, + request: SampleExport, + ) -> None: + self.samples.append(SampleRun(destination=destination, backend=backend, request=request)) + + +class FakeProgramBackend: + """The NSF backend as a setup binds it, holding each program chosen and the backend it gave.""" + + def __init__(self) -> None: + self.programs: List[NSFProgram] = [] + self.chosen: List[ExportBackend] = [] + + @property + def program(self) -> NSFProgram: + assert self.programs, "A program was expected to be chosen" + return self.programs[-1] + + def choosing(self, program: NSFProgram) -> ExportBackend: + backend = MagicMock(spec=ExportBackend) + self.programs.append(program) + self.chosen.append(backend) + return backend + + def extension(self, scope: ExportScope) -> str: # pylint: disable=unused-argument + return EXT_FILE_NSF diff --git a/tests/suite/player.py b/tests/suite/player.py index 94947c7fe..43eb2a7d5 100644 --- a/tests/suite/player.py +++ b/tests/suite/player.py @@ -26,6 +26,7 @@ from sampletones_player.compression.planes.order import PlaneOrder from sampletones_player.compression.planes.separate import planes_from_streams from sampletones_player.compression.planes.song import SongPlanes +from sampletones_player.compression.scheme import CompressionScheme from sampletones_player.compression.tokens.literal import LiteralToken from sampletones_player.registers.noise import NoiseRegisters from sampletones_player.registers.pulse import PulseRegisters @@ -135,6 +136,7 @@ def player_song( schedule=PlaySchedule.from_parameters(nes_frequency), loop_tick=loop_tick, seeds=(), + scheme=CompressionScheme.SEARCH, ) diff --git a/tests/unit/sampletones_application/categories/test_exports.py b/tests/unit/sampletones_application/categories/test_exports.py index b3518c506..935d83255 100644 --- a/tests/unit/sampletones_application/categories/test_exports.py +++ b/tests/unit/sampletones_application/categories/test_exports.py @@ -9,7 +9,7 @@ EXPORT_SAMPLE_MENU_LABELS, INSTRUMENT_EXPORT_FORMATS, ) -from sampletones_application.exports import build_export_backends +from sampletones_application.exports import ExportBackends from sampletones_application.utils.gui.shortcuts.ids import ( PROJECT_EXPORT_SHORTCUT_IDS, SAMPLE_EXPORT_SHORTCUT_IDS, @@ -21,7 +21,7 @@ @pytest.fixture(name="backends") def backends_fixture() -> Dict[ExportFormat, ExportBackend]: - return build_export_backends() + return ExportBackends.build().by_format def formats_supporting( diff --git a/tests/unit/sampletones_application/coordinators/export/test_instrument.py b/tests/unit/sampletones_application/coordinators/export/test_instrument.py index 49d85c0b6..c1b870f9a 100644 --- a/tests/unit/sampletones_application/coordinators/export/test_instrument.py +++ b/tests/unit/sampletones_application/coordinators/export/test_instrument.py @@ -10,7 +10,7 @@ from sampletones_application.coordinators.export.instrument import ( InstrumentExportCoordinator, ) -from sampletones_application.exports import build_export_backends +from sampletones_application.exports import ExportBackends from sampletones_application.logic.export.instrument.source import ( ExportableInstrument, exportable_instrument, @@ -39,7 +39,7 @@ def _source() -> InstrumentSource: def logic() -> MagicMock: mock = MagicMock() mock.suggested_directory = REMEMBERED_DIRECTORY - mock.backends = build_export_backends() + mock.backends = ExportBackends.build().by_format return mock diff --git a/tests/unit/sampletones_application/coordinators/export/test_nsf.py b/tests/unit/sampletones_application/coordinators/export/test_nsf.py new file mode 100644 index 000000000..879a951de --- /dev/null +++ b/tests/unit/sampletones_application/coordinators/export/test_nsf.py @@ -0,0 +1,257 @@ +from pathlib import Path +from typing import Any, Dict, Final, List, Optional +from unittest.mock import MagicMock + +import pytest + +from sampletones_application.coordinators.export import nsf as nsf_module +from sampletones_application.coordinators.export.nsf import NSFExportCoordinator +from sampletones_application.logic.export.nsf.logic import NSFExportLogic +from sampletones_application.logic.project.controller import ProjectController +from sampletones_application.logic.project.manager import ProjectManager +from sampletones_application.utils.file_dialogs.filter import FileFilter +from sampletones_application.view_model.shared.nsf.choices import NSFExportChoices +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 + +PROJECT_FILE: Final[Path] = Path("/home/user/projects/chiptune.stp") +PROJECT_DIRECTORY: Final[Path] = PROJECT_FILE.parent +INSTRUMENT_DIRECTORY: Final[Path] = Path("/home/user/instruments") +PROJECT_NAME: Final[str] = "chiptune" +SAMPLE_NAME: Final[str] = "Amen" +NTSC_FREQUENCY: Final[int] = 60 +CHOSEN: Final[Path] = Path("/home/user/programs/take one.nsf") + + +class _WindowRecorder: + """Stands in for the NSF export dialog, holding what it was told to show.""" + + def __init__(self) -> None: + self.view_models: List[NSFExportViewModel] = [] + self.visible = False + self.hides = 0 + self.on_choices_changed: Any = None + self.on_browse: Any = None + self.on_export: Any = None + self.on_close: Any = None + + def open(self, view_model: NSFExportViewModel) -> None: + self.visible = True + self.view_models.append(view_model) + + def update_view(self, view_model: NSFExportViewModel) -> None: + self.view_models.append(view_model) + + def hide(self) -> None: + self.hides += 1 + self.visible = False + + @property + def view(self) -> NSFExportViewModel: + assert self.view_models, "A view was expected to reach the window" + return self.view_models[-1] + + +class _SaveDialogRecorder: + """The OS save dialog as the coordinator asks it, answering with a stated path.""" + + def __init__(self) -> None: + self.answer: Optional[Path] = CHOSEN + self.requests: List[Dict[str, Any]] = [] + + def __call__(self, **kwargs: Any) -> Optional[Path]: + self.requests.append(kwargs) + return self.answer + + @property + def filters(self) -> List[FileFilter]: + assert self.requests, "A destination was expected to be asked for" + return list(self.requests[-1]["filters"]) + + +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. + """ + + def __init__( + self, + monkeypatch: pytest.MonkeyPatch, + *, + operation_active: bool = False, + ) -> None: + project_manager = ProjectManager() + project_manager.session.mark_loaded(PROJECT_NAME) + self.controller = ProjectController(project_manager) + self.session_manager = MagicMock() + self.session_manager.get_project_path.return_value = PROJECT_FILE + self.session_manager.get_instrument_path.return_value = INSTRUMENT_DIRECTORY + self.service = FakeNSFExportService() + self.backend = FakeProgramBackend() + self.logic = NSFExportLogic( + self.controller, + self.session_manager, + self.service, + self.backend, + is_operation_active=lambda: operation_active, + ) + + 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, + window=self.window, # type: ignore[arg-type] + language_manager=FakeLanguageManager(), # type: ignore[arg-type] + on_activity_changed=self._on_activity_changed, + ) + + def _on_activity_changed(self) -> None: + self.activity += 1 + + def edit(self, choices: NSFExportChoices) -> None: + self.window.on_choices_changed(choices) + + def browse(self) -> None: + self.window.on_browse() + + def export(self) -> None: + self.window.on_export() + + 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: + return NSFExportFixture(monkeypatch) + + +class TestOfferingTheSetup: + def test_a_project_opens_the_dialog_over_its_setup(self, nsf: NSFExportFixture) -> None: + nsf.coordinator.open_project() + + assert nsf.window.visible + assert nsf.window.view.destination == PROJECT_DIRECTORY / f"{PROJECT_NAME}{EXT_FILE_NSF}" + + def test_a_reconstruction_opens_the_dialog_over_its_slices(self, nsf: NSFExportFixture) -> None: + nsf.coordinator.open_sample(player_sample(SAMPLE_NAME, (), nes_frequency=NTSC_FREQUENCY)) + + assert nsf.window.visible + assert nsf.window.view.destination == INSTRUMENT_DIRECTORY / f"{SAMPLE_NAME}{EXT_FILE_NSF}" + + def test_opening_claims_the_application(self, nsf: NSFExportFixture) -> None: + nsf.coordinator.open_project() + + assert nsf.coordinator.is_active + assert nsf.activity == 1 + + def test_another_exclusive_operation_leaves_the_dialog_closed(self, monkeypatch: pytest.MonkeyPatch) -> None: + nsf = NSFExportFixture(monkeypatch, operation_active=True) + + nsf.coordinator.open_project() + + assert not nsf.window.visible + assert not nsf.activity + + def test_closing_the_setup_hands_the_application_back(self, nsf: NSFExportFixture) -> None: + nsf.coordinator.open_project() + + nsf.close() + + assert nsf.window.hides == 1 + assert not nsf.coordinator.is_active + assert nsf.activity == 2 + assert not nsf.service.projects + + +class TestTheEditsTheDialogReports: + def test_an_edit_comes_back_to_the_window(self, nsf: NSFExportFixture) -> None: + nsf.coordinator.open_project() + view = nsf.window.view + + nsf.edit(view.choices.with_channel(ChannelName.NOISE, False, view.offer)) + + assert not nsf.window.view.channel_sounded(ChannelName.NOISE) + + +class TestAskingForTheDestination: + def test_the_file_is_asked_for_from_where_it_stands(self, nsf: NSFExportFixture) -> None: + nsf.coordinator.open_project() + + nsf.browse() + + request = nsf.save_dialog.requests[-1] + assert request["initial_directory"] == PROJECT_DIRECTORY + assert request["default_filename"] == f"{PROJECT_NAME}{EXT_FILE_NSF}" + assert [file_filter.extensions for file_filter in nsf.save_dialog.filters] == [(EXT_FILE_NSF,)] + + def test_a_chosen_file_becomes_the_one_the_export_writes(self, nsf: NSFExportFixture) -> None: + nsf.coordinator.open_project() + + nsf.browse() + + assert nsf.window.view.destination == CHOSEN + + def test_a_dismissed_dialog_leaves_the_file_alone(self, nsf: NSFExportFixture) -> None: + nsf.coordinator.open_project() + nsf.save_dialog.answer = None + standing = nsf.window.view.destination + + nsf.browse() + + assert nsf.window.view.destination == standing + + +class TestHandingTheExportOver: + def test_the_dialog_leaves_the_screen_before_the_run_starts(self, nsf: NSFExportFixture) -> None: + nsf.coordinator.open_project() + + 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 + + def test_the_run_writes_the_program_the_dialog_stood_at(self, nsf: NSFExportFixture) -> None: + nsf.coordinator.open_project() + view = nsf.window.view + 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/test_sequencer.py b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py index d0a7c1c1e..d58931634 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py @@ -25,10 +25,7 @@ from sampletones_application.logic.history.snapshot import HistoryEntry from sampletones_application.logic.project.controller import ProjectController from sampletones_application.logic.project.manager import ProjectManager -from sampletones_application.logic.sequencer.channels import ( - ALL_CHANNELS, - SequencerChannelsLogic, -) +from sampletones_application.logic.sequencer.channels import SequencerChannelsLogic from sampletones_application.logic.sequencer.history_detail import ( SequencerHistoryDetail, ) @@ -62,7 +59,7 @@ HistoryDetailRole, HistoryDetailSegment, ) -from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.enums import ALL_CHANNELS, ChannelName from sampletones_core.features.envelope import Envelope from sampletones_core.formats.famitracker.voice import ImportedVoice, InstrumentOmission from sampletones_core.project.song_position import SongPosition diff --git a/tests/unit/sampletones_application/coordinators/test_project.py b/tests/unit/sampletones_application/coordinators/test_project.py index a32fd6344..94e244027 100644 --- a/tests/unit/sampletones_application/coordinators/test_project.py +++ b/tests/unit/sampletones_application/coordinators/test_project.py @@ -4,7 +4,9 @@ import pytest +from sampletones_application.coordinators import project as project_module from sampletones_application.coordinators.project import ProjectCoordinator +from sampletones_core.exports.format import ExportFormat from sampletones_shared.exceptions import ( IncompatibleProjectVersionError, IncorrectReconstructionDataError, @@ -13,6 +15,7 @@ NotAValidArchiveError, UnhandledProjectError, ) +from sampletones_shared.paths.extensions import EXT_FILE_MODULE from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase @@ -25,6 +28,7 @@ def project_coordinator() -> ProjectCoordinator: MagicMock(), MagicMock(), export_backends={}, + format_setups={}, dialogs=MagicMock(), language_manager=MagicMock(), on_tab_switch=MagicMock(), @@ -152,3 +156,70 @@ def test_manual_load_shows_error_dialog( project_coordinator._dialogs.show_error.assert_called_once_with(test_case.failure) project_coordinator._session_manager.set_current_project.assert_not_called() + + +class TestAFormatWithASetupOpensIt: + """A format asking for its own choices opens its setup where the save dialog would, and every + other format asks for the destination alone.""" + + @pytest.fixture + def setup(self) -> MagicMock: + return MagicMock() + + @pytest.fixture + def save_dialog(self, monkeypatch: pytest.MonkeyPatch) -> MagicMock: + dialog = MagicMock(return_value=None) + monkeypatch.setattr(project_module, "save_file_dialog", dialog) + return dialog + + @pytest.fixture + def coordinator(self, setup: MagicMock) -> ProjectCoordinator: + backend = MagicMock() + backend.extension.return_value = EXT_FILE_MODULE + return ProjectCoordinator( + MagicMock(), + MagicMock(), + MagicMock(), + MagicMock(), + export_backends={ExportFormat.FAMITRACKER: backend}, + format_setups={ExportFormat.NSF: setup}, + dialogs=MagicMock(), + language_manager=MagicMock(), + on_tab_switch=MagicMock(), + on_session_state_changed=MagicMock(), + ) + + def test_a_format_with_a_setup_opens_it( + self, + coordinator: ProjectCoordinator, + setup: MagicMock, + save_dialog: MagicMock, + ) -> None: + coordinator.export_project_dialog(ExportFormat.NSF) + + setup.open_project.assert_called_once_with() + save_dialog.assert_not_called() + + def test_a_format_asking_for_the_file_alone_opens_the_save_dialog( + self, + coordinator: ProjectCoordinator, + setup: MagicMock, + save_dialog: MagicMock, + ) -> None: + coordinator.export_project_dialog(ExportFormat.FAMITRACKER) + + save_dialog.assert_called_once() + setup.open_project.assert_not_called() + + def test_a_closed_project_opens_nothing( + self, + coordinator: ProjectCoordinator, + setup: MagicMock, + save_dialog: MagicMock, + ) -> None: + coordinator._project_controller.is_open = False + + coordinator.export_project_dialog(ExportFormat.NSF) + + setup.open_project.assert_not_called() + save_dialog.assert_not_called() diff --git a/tests/unit/sampletones_application/logic/export/instrument/test_logic.py b/tests/unit/sampletones_application/logic/export/instrument/test_logic.py index 15e4b20a4..126d6c352 100644 --- a/tests/unit/sampletones_application/logic/export/instrument/test_logic.py +++ b/tests/unit/sampletones_application/logic/export/instrument/test_logic.py @@ -5,7 +5,7 @@ import pytest -from sampletones_application.exports import build_export_backends +from sampletones_application.exports import ExportBackends from sampletones_application.logic.export.instrument.logic import InstrumentExportLogic from sampletones_application.logic.export.instrument.source import ( exportable_instrument, @@ -61,7 +61,7 @@ def export_service() -> MagicMock: def export_backends() -> Dict[ExportFormat, MagicMock]: """Stands in for the real backends while declaring the scopes and extensions they do.""" backends: Dict[ExportFormat, MagicMock] = {} - for export_format, backend in build_export_backends().items(): + for export_format, backend in ExportBackends.build().by_format.items(): stub = MagicMock() stub.supported_scopes = backend.supported_scopes stub.extension.side_effect = backend.extension diff --git a/tests/unit/sampletones_application/logic/export/nsf/__init__.py b/tests/unit/sampletones_application/logic/export/nsf/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_application/logic/export/nsf/source/__init__.py b/tests/unit/sampletones_application/logic/export/nsf/source/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_application/logic/export/nsf/source/test_sample.py b/tests/unit/sampletones_application/logic/export/nsf/source/test_sample.py new file mode 100644 index 000000000..e2647320b --- /dev/null +++ b/tests/unit/sampletones_application/logic/export/nsf/source/test_sample.py @@ -0,0 +1,18 @@ +from typing import Final + +import pytest + +from sampletones_application.logic.export.nsf.source.sample import SampleSource +from sampletones_application.view_model.shared.nsf.offer import FIRST_FRAME +from tests.suite.player import player_sample + +SAMPLE_NAME: Final[str] = "Amen" +NTSC_FREQUENCY: Final[int] = 60 + + +class TestAReconstructionHasNoFrames: + def test_asking_where_a_frame_starts_is_refused(self) -> None: + source = SampleSource(request=player_sample(SAMPLE_NAME, (), nes_frequency=NTSC_FREQUENCY)) + + with pytest.raises(ValueError): + source.frame_tick(FIRST_FRAME) diff --git a/tests/unit/sampletones_application/logic/export/nsf/test_logic.py b/tests/unit/sampletones_application/logic/export/nsf/test_logic.py new file mode 100644 index 000000000..2ed548e41 --- /dev/null +++ b/tests/unit/sampletones_application/logic/export/nsf/test_logic.py @@ -0,0 +1,294 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Final, List, Optional +from unittest.mock import MagicMock + +import pytest + +from sampletones_application.logic.export.nsf.logic import NSFExportLogic +from sampletones_application.logic.project.controller import ProjectController +from sampletones_application.logic.project.manager import ProjectManager +from sampletones_application.view_model.shared.nsf.choices import NSFExportChoices +from sampletones_application.view_model.shared.nsf.repeat import NSFRepeat +from sampletones_application.view_model.shared.nsf.view import NSFExportViewModel +from sampletones_core.constants.enums import ChannelName +from sampletones_core.exports.request import SampleExport +from sampletones_core.timing import SongTiming +from sampletones_player.builder import SONG_START +from sampletones_player.compression.scheme import CompressionScheme, offered_schemes +from sampletones_player.export.program import NSFProgram +from sampletones_shared.paths.extensions import EXT_FILE_NSF +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase +from tests.suite.nsf import PROGRAM_TITLE, FakeNSFExportService, FakeProgramBackend +from tests.suite.player import PLAYER_REFERENCE_PITCH, player_features, player_instrument, player_sample + +PROJECT_NAME: Final[str] = "chiptune" +SAMPLE_NAME: Final[str] = "Amen" +EXTRA_FRAMES: Final[int] = 3 +LOOP_FRAME: Final[int] = 2 +NTSC_FREQUENCY: Final[int] = 60 +SHORT_FRAMES: Final[int] = 8 +LONG_FRAMES: Final[int] = 24 +CHOSEN_DESTINATION: Final[Path] = Path("/home/user/programs/take one.nsf") + + +def two_slice_sample() -> SampleExport: + """A reconstruction sounding a short lead and a longer bass, neither of them repeating.""" + return player_sample( + SAMPLE_NAME, + ( + player_instrument( + "lead", + ChannelName.PULSE1, + player_features(SHORT_FRAMES, PLAYER_REFERENCE_PITCH, duty_cycle=True), + nes_frequency=NTSC_FREQUENCY, + loop=False, + ), + player_instrument( + "bass", + ChannelName.TRIANGLE, + player_features(LONG_FRAMES, PLAYER_REFERENCE_PITCH, duty_cycle=False), + nes_frequency=NTSC_FREQUENCY, + loop=False, + ), + ), + nes_frequency=NTSC_FREQUENCY, + ) + + +class NSFExportFixture: + """An NSF export setup over a real project, with a service and a backend that record the run.""" + + def __init__(self, directory: Path, *, operation_active: bool = False) -> None: + project_manager = ProjectManager() + project_manager.session.mark_loaded(PROJECT_NAME) + self.controller = ProjectController(project_manager) + for _ in range(EXTRA_FRAMES): + self.controller.append_frame() + + self.directory = directory + self.session_manager = MagicMock() + self.session_manager.get_project_path.return_value = directory + self.session_manager.get_instrument_path.return_value = directory + self.service = FakeNSFExportService() + self.backend = FakeProgramBackend() + self.views: List[NSFExportViewModel] = [] + self.destinations: List[Path] = [] + self.logic = NSFExportLogic( + self.controller, + self.session_manager, + self.service, + self.backend, + is_operation_active=lambda: operation_active, + ) + self.logic.on_view_changed = self.views.append + self.logic.on_choose_destination = self.destinations.append + + @property + def view(self) -> NSFExportViewModel: + assert self.views, "A view was expected to be emitted" + return self.views[-1] + + def edit(self, choices: NSFExportChoices) -> None: + self.logic.apply(choices) + + +@pytest.fixture +def export(tmp_path: Path) -> NSFExportFixture: + return NSFExportFixture(tmp_path) + + +class TestOfferingTheProject: + def test_the_dialog_opens_on_a_file_named_after_the_project(self, export: NSFExportFixture) -> None: + export.logic.open_project() + + assert export.view.destination == export.directory / f"{PROJECT_NAME}{EXT_FILE_NSF}" + + def test_the_dialog_opens_on_the_program_the_project_is_written_as(self, export: NSFExportFixture) -> None: + export.logic.open_project() + + program = NSFProgram.for_project(export.controller.project) + assert export.view.choices == NSFExportChoices.initial(program, export.view.offer) + + def test_every_channel_frame_and_scheme_is_offered(self, export: NSFExportFixture) -> None: + export.logic.open_project() + + offer = export.view.offer + assert offer.channels == tuple(ChannelName.items()) + assert offer.frame_count == export.controller.order_length + assert offer.schemes == offered_schemes(seeded=True) + + def test_the_length_is_the_whole_orders(self, export: NSFExportFixture) -> None: + export.logic.open_project() + + project = export.controller.project + assert export.view.ticks == SongTiming.from_project(project).frame_tick(project.song.order_length()) + assert export.view.nes_frequency == project.settings.nes_frequency + + def test_the_setup_occupies_the_application_from_the_dialog_opening(self, export: NSFExportFixture) -> None: + assert not export.logic.is_active + + assert export.logic.open_project() + + assert export.logic.is_active + + def test_another_exclusive_operation_holds_the_dialog_closed(self, tmp_path: Path) -> None: + export = NSFExportFixture(tmp_path, operation_active=True) + + assert not export.logic.open_project() + assert not export.logic.is_active + assert not export.views + + def test_closing_releases_the_application(self, export: NSFExportFixture) -> None: + export.logic.open_project() + + export.logic.close() + + assert not export.logic.is_active + + +class TestOfferingASample: + def test_the_channels_are_the_ones_the_slices_sound_on(self, export: NSFExportFixture) -> None: + export.logic.open_sample(two_slice_sample()) + + assert export.view.channels == (ChannelName.PULSE1, ChannelName.TRIANGLE) + assert NSFRepeat.FROM_FRAME not in export.view.repeats + assert export.view.schemes == offered_schemes(seeded=False) + + def test_the_dialog_opens_on_a_file_named_after_the_reconstruction(self, export: NSFExportFixture) -> None: + export.logic.open_sample(two_slice_sample()) + + assert export.view.destination == export.directory / f"{SAMPLE_NAME}{EXT_FILE_NSF}" + + def test_the_song_lasts_as_long_as_the_longest_sounding_slice(self, export: NSFExportFixture) -> None: + export.logic.open_sample(two_slice_sample()) + assert export.view.ticks == LONG_FRAMES + + export.edit(export.view.choices.with_channel(ChannelName.TRIANGLE, False, export.view.offer)) + + assert export.view.ticks == SHORT_FRAMES + + +class TestTheDestination: + def test_the_destination_is_asked_for_from_where_it_stands(self, export: NSFExportFixture) -> None: + export.logic.open_project() + + export.logic.request_destination() + + assert export.destinations == [export.view.destination] + + def test_a_chosen_destination_is_the_one_written(self, export: NSFExportFixture) -> None: + export.logic.open_project() + export.logic.set_destination(CHOSEN_DESTINATION) + + export.logic.start() + + assert export.service.projects[0].destination == CHOSEN_DESTINATION + + +class TestHandingTheProjectOver: + def test_the_service_writes_the_project_through_the_chosen_backend(self, export: NSFExportFixture) -> None: + export.logic.open_project() + destination = export.view.destination + + assert export.logic.start() + + run = export.service.projects[0] + assert run.destination == destination + assert run.backend is export.backend.chosen[0] + assert run.request == export.controller.export_request + + def test_the_program_carries_the_choices(self, export: NSFExportFixture) -> None: + export.logic.open_project() + offer = export.view.offer + choices = ( + export.view.choices.with_title(PROGRAM_TITLE) + .with_channel(ChannelName.NOISE, False, offer) + .with_repeat(NSFRepeat.FROM_FRAME, offer) + .with_loop_frame(LOOP_FRAME, offer) + .with_scheme(CompressionScheme.RUNS, offer) + ) + export.edit(choices) + + export.logic.start() + + program = export.backend.program + assert program.information == choices.information + assert program.channels == frozenset(ChannelName.items()) - {ChannelName.NOISE} + assert program.loop_tick == SongTiming.from_project(export.controller.project).frame_tick(LOOP_FRAME) + assert program.scheme == CompressionScheme.RUNS + + def test_handing_over_closes_the_setup(self, export: NSFExportFixture) -> None: + export.logic.open_project() + + export.logic.start() + + assert not export.logic.is_active + + def test_a_program_sounding_nothing_stays_in_the_setup(self, export: NSFExportFixture) -> None: + export.logic.open_project() + choices = export.view.choices + for channel in ChannelName.items(): + choices = choices.with_channel(channel, False, export.view.offer) + export.edit(choices) + + assert not export.logic.start() + + assert not export.service.projects + assert export.logic.is_active + + def test_the_project_folder_stays_where_the_project_left_it(self, export: NSFExportFixture) -> None: + export.logic.open_project() + + export.logic.start() + + export.session_manager.set_project_path.assert_not_called() + + def test_an_export_starts_from_the_open_setup_alone(self, export: NSFExportFixture) -> None: + export.logic.open_project() + export.logic.start() + + assert not export.logic.start() + + assert len(export.service.projects) == 1 + + +class TestTheRepeatLandsOnTheSongsTicks(BaseTestSuite): + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + repeat: NSFRepeat + expected: Optional[int] + + test_cases = ( + TestCase(label="once", repeat=NSFRepeat.ONCE, expected=None), + TestCase(label="from_start", repeat=NSFRepeat.FROM_START, expected=SONG_START), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_a_sample_repeats_where_the_choice_says(self, test_case: TestCase, tmp_path: Path) -> None: + export = NSFExportFixture(tmp_path) + export.logic.open_sample(two_slice_sample()) + export.edit(export.view.choices.with_repeat(test_case.repeat, export.view.offer)) + + export.logic.start() + + assert export.backend.program.loop_tick == test_case.expected + + +class TestHandingTheSampleOver: + def test_the_service_writes_the_slices(self, export: NSFExportFixture) -> None: + sample = two_slice_sample() + export.logic.open_sample(sample) + + export.logic.start() + + assert export.service.samples[0].request == sample + + def test_the_next_instrument_export_opens_where_this_one_was_written(self, export: NSFExportFixture) -> None: + export.logic.open_sample(two_slice_sample()) + export.logic.set_destination(CHOSEN_DESTINATION) + + export.logic.start() + + export.session_manager.set_instrument_path.assert_called_once_with(CHOSEN_DESTINATION.parent) diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py index 23720b65b..674586df1 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py @@ -6,7 +6,7 @@ import numpy as np import pytest -from sampletones_application.exports import build_export_backends +from sampletones_application.exports import ExportBackends from sampletones_application.logic.reconstruction.data import ReconstructionData from sampletones_application.logic.reconstruction.manager import ReconstructionManager from sampletones_application.logic.reconstruction.reconstruction import ( @@ -105,7 +105,7 @@ def mock_export_backends() -> Dict[ExportFormat, MagicMock]: the registry's backend declares and leaves only the writing to the mock. """ backends: Dict[ExportFormat, MagicMock] = {} - for export_format, backend in build_export_backends().items(): + for export_format, backend in ExportBackends.build().by_format.items(): stub = MagicMock() stub.supported_scopes = backend.supported_scopes stub.extension.side_effect = backend.extension @@ -830,6 +830,47 @@ def test_handle_export_instruments_confirmed_writes_through_the_chosen_tracker( assert backend is mock_export_backends[case.export_format] +class TestReconstructionPanelLogicSampleRequest: + """A format setting its export up in a dialog of its own names the slices before a destination + exists, so the request carries the reconstruction's own name.""" + + def test_with_no_data_raises_assertion_error( + self, + panel_logic: ReconstructionPanelLogic, + ) -> None: + with pytest.raises(AssertionError): + panel_logic.sample_request() + + def test_the_request_is_named_after_the_reconstruction( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + loaded_data: ReconstructionData, + ) -> None: + mock_reconstruction_manager.current_reconstruction = loaded_data + + request = panel_logic.sample_request() + + assert request.name == loaded_data.name + assert [instrument.name for instrument in request.instruments] == [f"{loaded_data.name} (pulse1)"] + + def test_the_request_holds_the_slices_a_confirmed_export_writes( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + loaded_data: ReconstructionData, + mock_export_service: MagicMock, + tmp_path: Path, + ) -> None: + mock_reconstruction_manager.current_reconstruction = loaded_data + panel_logic.handle_export_instruments_confirmed( + tmp_path / f"{loaded_data.name}{EXT_FILE_INSTRUMENT}", + ExportFormat.FAMITRACKER, + ) + + assert panel_logic.sample_request() == mock_export_service.export_sample.call_args.args[2] + + class TestReconstructionPanelLogicExportWav: def test_request_export_wav_dialog_with_no_data_raises_assertion_error( self, diff --git a/tests/unit/sampletones_application/logic/sequencer/playback/conftest.py b/tests/unit/sampletones_application/logic/sequencer/playback/conftest.py index 36c5c8c7a..5847a9bd7 100644 --- a/tests/unit/sampletones_application/logic/sequencer/playback/conftest.py +++ b/tests/unit/sampletones_application/logic/sequencer/playback/conftest.py @@ -6,11 +6,10 @@ from sampletones_application.logic.project.controller import ProjectController from sampletones_application.logic.project.manager import ProjectManager -from sampletones_application.logic.sequencer.channels import ALL_CHANNELS from sampletones_application.logic.sequencer.playback.synthesizer import RowSynthesizer from sampletones_core.configs import Config from sampletones_core.constants.audio import DEFAULT_SAMPLE_RATE -from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.enums import ALL_CHANNELS, ChannelName from sampletones_core.project.voices.creation import new_instrument from sampletones_core.project.voices.instrument import Instrument from sampletones_core.project.voices.note_off import NoteOff 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 bd980f975..64859d917 100644 --- a/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py +++ b/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py @@ -5,11 +5,10 @@ import pytest from sampletones_application.logic.project.controller import ProjectController -from sampletones_application.logic.sequencer.channels import ALL_CHANNELS from sampletones_application.logic.sequencer.playback.synthesizer import RowSynthesizer from sampletones_core.configs import Config from sampletones_core.constants.audio import DEFAULT_SAMPLE_RATE -from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.constants.enums import ALL_CHANNELS, ChannelName, FeatureKey from sampletones_core.constants.general import MAX_VOLUME from sampletones_core.features import CHANNEL_FEATURE_DEFAULTS from sampletones_core.performance import ChannelPerformance diff --git a/tests/unit/sampletones_application/logic/sequencer/test_channels.py b/tests/unit/sampletones_application/logic/sequencer/test_channels.py index 2863754d8..00ebc225b 100644 --- a/tests/unit/sampletones_application/logic/sequencer/test_channels.py +++ b/tests/unit/sampletones_application/logic/sequencer/test_channels.py @@ -4,13 +4,12 @@ import pytest from sampletones_application.logic.sequencer.channels import ( - ALL_CHANNELS, SequencerChannelsLogic, ) from sampletones_application.view_model.sequencer.channels import ( SequencerChannelsViewModel, ) -from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.enums import ALL_CHANNELS, ChannelName from tests.suite.case import BaseTestCase Gesture = Callable[[SequencerChannelsLogic], None] diff --git a/tests/unit/sampletones_application/test_busy_lock.py b/tests/unit/sampletones_application/test_busy_lock.py index dd84a2d2f..aa0c52709 100644 --- a/tests/unit/sampletones_application/test_busy_lock.py +++ b/tests/unit/sampletones_application/test_busy_lock.py @@ -16,6 +16,7 @@ def _application( converter_running: bool = False, library_generating: bool = False, rendering: bool = False, + setting_up_nsf: bool = False, exporting: bool = False, ) -> Application: """An application with only the attributes the busy methods touch, bypassing the full composition @@ -28,6 +29,8 @@ def _application( application._reconstructions_tab = MagicMock() application._render_coordinator = MagicMock() application._render_coordinator.is_active = rendering + application._nsf_exports = MagicMock() + application._nsf_exports.is_active = setting_up_nsf application.export_service = MagicMock() application.export_service.is_running.return_value = exporting application._update_menu = MagicMock() @@ -47,6 +50,9 @@ def test_busy_while_library_generates(self) -> None: def test_busy_while_song_renders(self) -> None: assert _application(rendering=True)._is_operation_active() is True + def test_busy_while_an_nsf_export_is_set_up(self) -> None: + assert _application(setting_up_nsf=True)._is_operation_active() is True + def test_busy_while_an_export_writes(self) -> None: assert _application(exporting=True)._is_operation_active() is True @@ -101,7 +107,7 @@ def test_refresh_reaches_the_menu(self) -> None: application._refresh_busy_state() application._update_menu.assert_called_once_with() - def test_a_render_edge_refreshes_the_converter_view(self) -> None: + def test_a_dialog_edge_refreshes_the_converter_view(self) -> None: application = _application() - application._on_render_activity_changed() + application._on_dialog_activity_changed() application._main_tab.refresh_converter_view.assert_called_once_with() diff --git a/tests/unit/sampletones_application/ui/panels/dialogs/test_nsf.py b/tests/unit/sampletones_application/ui/panels/dialogs/test_nsf.py new file mode 100644 index 000000000..b2a09c26d --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/dialogs/test_nsf.py @@ -0,0 +1,306 @@ +from pathlib import Path +from typing import Final, List + +import dearpygui.dearpygui as dpg +import pytest + +from sampletones_application.categories.context import channel_label +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.compose import compose_tag +from sampletones_application.tags.general import SUF_BUTTON +from sampletones_application.tags.settings import ( + TAG_SETTINGS_NSF_BUTTON_BROWSE, + TAG_SETTINGS_NSF_BUTTON_CANCEL, + TAG_SETTINGS_NSF_BUTTON_EXPORT, + TAG_SETTINGS_NSF_CHECKBOX_CHANNEL, + TAG_SETTINGS_NSF_COMBO_REPEAT, + TAG_SETTINGS_NSF_COMBO_SCHEME, + TAG_SETTINGS_NSF_GROUP_LOOP_FRAME, + TAG_SETTINGS_NSF_INPUT_ARTIST, + TAG_SETTINGS_NSF_INPUT_LOOP_FRAME, + TAG_SETTINGS_NSF_INPUT_TITLE, + TAG_SETTINGS_NSF_PATH_DESTINATION, + TAG_SETTINGS_NSF_TEXT_FRAME_COUNT, + TAG_SETTINGS_NSF_TEXT_LENGTH, + TAG_SETTINGS_NSF_TEXT_NO_CHANNEL, + TAG_SETTINGS_NSF_TEXT_SCHEME_DESCRIPTION, + TAG_SETTINGS_NSF_TEXT_TITLE_SIZE, +) +from sampletones_application.ui.elements.status import GUIStatusBar +from sampletones_application.ui.panels.dialogs.nsf import GUINSFExportWindow +from sampletones_application.utils.gui.keyboard import KeyRouter +from sampletones_application.view_model.shared.nsf.choices import NSFExportChoices +from sampletones_application.view_model.shared.nsf.offer import NSFExportOffer +from sampletones_application.view_model.shared.nsf.repeat import NSFRepeat +from sampletones_application.view_model.shared.nsf.view import NSFExportViewModel +from sampletones_core.constants.enums import ChannelName +from sampletones_player.compression.scheme import CompressionScheme +from sampletones_player.nsf.information import fit_field +from sampletones_player.specification.nsf import STRING_TEXT_SIZE +from sampletones_shared.utils.system.paths import shorten_path +from tests.suite.nsf import PROGRAM_ARTIST, PROGRAM_TITLE, project_offer, sample_offer, standing_choices +from tests.suite.shortcuts import shipped_source + +LANGUAGE_MANAGER: Final[LanguageManager] = LanguageManager(LANG_EN) +DESTINATION: Final[Path] = Path("/home/user/programs/chiptune.nsf") +SONG_TICKS: Final[int] = 10800 +NES_FREQUENCY: Final[int] = 60 +LOOP_FRAME: Final[int] = 2 +OVERLONG_TITLE: Final[str] = "Überraschungsmelodie für Konsolen" + + +def export_view(offer: NSFExportOffer, choices: NSFExportChoices) -> NSFExportViewModel: + return NSFExportViewModel( + offer=offer, + choices=choices, + destination=DESTINATION, + ticks=SONG_TICKS, + nes_frequency=NES_FREQUENCY, + ) + + +@pytest.fixture(name="window") +def window_fixture(dpg_context: None, layout_config: LayoutConfig) -> GUINSFExportWindow: + return GUINSFExportWindow( + layout=layout_config.settings, + path_colors=layout_config.general.colors.paths, + text_colors=layout_config.general.colors.text, + language_manager=LANGUAGE_MANAGER, + key_router=KeyRouter(), + shortcut_source=shipped_source(), + status_bar=GUIStatusBar(display_time=1.0), + ) + + +def draw(window: GUINSFExportWindow, view_model: NSFExportViewModel) -> None: + """Builds the widget tree for the given state, the way ``open`` does without a live frame.""" + window.update_view(view_model) + window.create_window() + + +def draw_project(window: GUINSFExportWindow) -> NSFExportViewModel: + offer = project_offer() + view_model = export_view(offer, standing_choices(offer)) + draw(window, view_model) + return view_model + + +def channel_tag(channel: ChannelName) -> str: + return compose_tag(TAG_SETTINGS_NSF_CHECKBOX_CHANNEL, channel.value) + + +def press(tag: str) -> None: + dpg.get_item_callback(compose_tag(tag, SUF_BUTTON))() + + +def edit(tag: str, value: object) -> None: + """Reports an edit the way DearPyGui does, handing the user data to a control that carries some.""" + user_data = dpg.get_item_user_data(tag) + if user_data is None: + dpg.get_item_callback(tag)(tag, value) + else: + dpg.get_item_callback(tag)(tag, value, user_data) + + +class TestTheProgramText: + def test_each_field_shows_its_text(self, window: GUINSFExportWindow) -> None: + draw_project(window) + + assert dpg.get_value(TAG_SETTINGS_NSF_INPUT_TITLE) == PROGRAM_TITLE + assert dpg.get_value(TAG_SETTINGS_NSF_INPUT_ARTIST) == PROGRAM_ARTIST + + def test_each_field_states_the_bytes_its_text_takes(self, window: GUINSFExportWindow) -> None: + view_model = draw_project(window) + + expected = view_model.text_size_label(PROGRAM_TITLE, LANGUAGE_MANAGER["settings.nsf.template.text_size"]) + assert dpg.get_value(TAG_SETTINGS_NSF_TEXT_TITLE_SIZE) == expected + + def test_a_typed_title_is_reported_as_the_header_holds_it(self, window: GUINSFExportWindow) -> None: + reported: List[NSFExportChoices] = [] + window.on_choices_changed = reported.append + draw_project(window) + + edit(TAG_SETTINGS_NSF_INPUT_TITLE, OVERLONG_TITLE * 2) + + assert reported[-1].information.title == fit_field(OVERLONG_TITLE * 2) + assert len(reported[-1].information.title.encode()) <= STRING_TEXT_SIZE + + +class TestTheChannels: + def test_a_channel_the_source_sounds_takes_a_tick(self, window: GUINSFExportWindow) -> None: + offer = sample_offer() + draw(window, export_view(offer, standing_choices(offer))) + + for channel in ChannelName.items(): + configuration = dpg.get_item_configuration(channel_tag(channel)) + assert configuration["enabled"] == (channel in offer.channels) + assert dpg.get_value(channel_tag(channel)) == (channel in offer.channels) + + def test_each_channel_is_named_as_everywhere_else(self, window: GUINSFExportWindow) -> None: + draw_project(window) + + for channel in ChannelName.items(): + label = channel_label(LANGUAGE_MANAGER, channel) + assert dpg.get_item_configuration(channel_tag(channel))["label"] == label + + def test_unticking_a_channel_reports_it(self, window: GUINSFExportWindow) -> None: + reported: List[NSFExportChoices] = [] + window.on_choices_changed = reported.append + draw_project(window) + + edit(channel_tag(ChannelName.NOISE), False) + + assert ChannelName.NOISE not in reported[-1].channels + + def test_a_program_sounding_nothing_says_why_it_waits(self, window: GUINSFExportWindow) -> None: + offer = project_offer() + choices = standing_choices(offer).model_copy(update={"channels": frozenset()}) + draw(window, export_view(offer, choices)) + + assert dpg.get_item_configuration(TAG_SETTINGS_NSF_TEXT_NO_CHANNEL)["show"] + assert not dpg.get_item_configuration(TAG_SETTINGS_NSF_BUTTON_EXPORT)["enabled"] + + def test_a_sounding_program_says_nothing(self, window: GUINSFExportWindow) -> None: + draw_project(window) + + assert not dpg.get_item_configuration(TAG_SETTINGS_NSF_TEXT_NO_CHANNEL)["show"] + assert dpg.get_item_configuration(TAG_SETTINGS_NSF_BUTTON_EXPORT)["enabled"] + + +class TestThePlayback: + def test_the_repeats_offered_are_the_sources(self, window: GUINSFExportWindow) -> None: + offer = sample_offer() + draw(window, export_view(offer, standing_choices(offer))) + + assert dpg.get_item_configuration(TAG_SETTINGS_NSF_COMBO_REPEAT)["items"] == [ + LANGUAGE_MANAGER["settings.nsf.label.repeat_once"], + LANGUAGE_MANAGER["settings.nsf.label.repeat_from_start"], + ] + + def test_the_frame_is_asked_for_once_the_repeat_returns_to_one(self, window: GUINSFExportWindow) -> None: + offer = project_offer() + choices = standing_choices(offer).with_repeat(NSFRepeat.FROM_FRAME, offer).with_loop_frame(LOOP_FRAME, offer) + view_model = export_view(offer, choices) + draw(window, view_model) + + assert dpg.get_item_configuration(TAG_SETTINGS_NSF_GROUP_LOOP_FRAME)["show"] + assert dpg.get_value(TAG_SETTINGS_NSF_INPUT_LOOP_FRAME) == view_model.loop_frame_label( + LANGUAGE_MANAGER["settings.nsf.template.frame"] + ) + assert dpg.get_value(TAG_SETTINGS_NSF_TEXT_FRAME_COUNT) == view_model.frame_count_label( + LANGUAGE_MANAGER["settings.nsf.template.frame_count"] + ) + + def test_a_repeat_from_the_start_leaves_the_frame_out(self, window: GUINSFExportWindow) -> None: + draw_project(window) + + assert not dpg.get_item_configuration(TAG_SETTINGS_NSF_GROUP_LOOP_FRAME)["show"] + assert not dpg.get_item_configuration(TAG_SETTINGS_NSF_INPUT_LOOP_FRAME)["enabled"] + + def test_the_length_is_stated(self, window: GUINSFExportWindow) -> None: + view_model = draw_project(window) + + expected = view_model.length_label(LANGUAGE_MANAGER["settings.nsf.template.length"]) + assert dpg.get_value(TAG_SETTINGS_NSF_TEXT_LENGTH) == expected + + def test_picking_a_repeat_reports_it(self, window: GUINSFExportWindow) -> None: + reported: List[NSFExportChoices] = [] + window.on_choices_changed = reported.append + draw_project(window) + + edit(TAG_SETTINGS_NSF_COMBO_REPEAT, LANGUAGE_MANAGER["settings.nsf.label.repeat_from_frame"]) + + assert reported[-1].repeat == NSFRepeat.FROM_FRAME + + def test_a_typed_frame_is_read_in_hexadecimal(self, window: GUINSFExportWindow) -> None: + reported: List[NSFExportChoices] = [] + window.on_choices_changed = reported.append + draw_project(window) + + edit(TAG_SETTINGS_NSF_INPUT_LOOP_FRAME, "0A") + + assert reported[-1].loop_frame == 10 + + def test_a_field_typed_empty_reports_nothing(self, window: GUINSFExportWindow) -> None: + reported: List[NSFExportChoices] = [] + window.on_choices_changed = reported.append + draw_project(window) + + edit(TAG_SETTINGS_NSF_INPUT_LOOP_FRAME, "") + + assert not reported + + +class TestTheCompression: + def test_the_schemes_offered_are_the_sources(self, window: GUINSFExportWindow) -> None: + offer = sample_offer() + draw(window, export_view(offer, standing_choices(offer))) + + assert LANGUAGE_MANAGER["settings.nsf.label.scheme_instruments"] not in ( + dpg.get_item_configuration(TAG_SETTINGS_NSF_COMBO_SCHEME)["items"] + ) + + def test_the_chosen_scheme_is_described(self, window: GUINSFExportWindow) -> None: + offer = project_offer() + draw(window, export_view(offer, standing_choices(offer).with_scheme(CompressionScheme.RUNS, offer))) + + assert dpg.get_value(TAG_SETTINGS_NSF_COMBO_SCHEME) == LANGUAGE_MANAGER["settings.nsf.label.scheme_runs"] + assert ( + dpg.get_value(TAG_SETTINGS_NSF_TEXT_SCHEME_DESCRIPTION) + == LANGUAGE_MANAGER["settings.nsf.message.scheme_runs"] + ) + + def test_picking_a_scheme_reports_it(self, window: GUINSFExportWindow) -> None: + reported: List[NSFExportChoices] = [] + window.on_choices_changed = reported.append + draw_project(window) + + edit(TAG_SETTINGS_NSF_COMBO_SCHEME, LANGUAGE_MANAGER["settings.nsf.label.scheme_none"]) + + assert reported[-1].scheme == CompressionScheme.NONE + + +class TestTheActions: + def test_the_file_is_shown(self, window: GUINSFExportWindow) -> None: + draw_project(window) + + assert dpg.get_value(TAG_SETTINGS_NSF_PATH_DESTINATION) == shorten_path(DESTINATION) + + def test_the_browse_button_asks_for_a_file(self, window: GUINSFExportWindow) -> None: + asked: List[None] = [] + window.on_browse = lambda: asked.append(None) + draw_project(window) + + press(TAG_SETTINGS_NSF_BUTTON_BROWSE) + + assert asked + + def test_the_export_button_hands_the_export_over(self, window: GUINSFExportWindow) -> None: + exported: List[None] = [] + window.on_export = lambda: exported.append(None) + draw_project(window) + + press(TAG_SETTINGS_NSF_BUTTON_EXPORT) + + assert exported + + def test_a_program_sounding_nothing_is_kept_from_the_export(self, window: GUINSFExportWindow) -> None: + exported: List[None] = [] + window.on_export = lambda: exported.append(None) + offer = project_offer() + draw(window, export_view(offer, standing_choices(offer).model_copy(update={"channels": frozenset()}))) + + press(TAG_SETTINGS_NSF_BUTTON_EXPORT) + + assert not exported + + def test_the_cancel_button_closes_the_dialog(self, window: GUINSFExportWindow) -> None: + closed: List[None] = [] + window.on_close = lambda: closed.append(None) + draw_project(window) + + press(TAG_SETTINGS_NSF_BUTTON_CANCEL) + + assert closed diff --git a/tests/unit/sampletones_application/view_model/shared/nsf/__init__.py b/tests/unit/sampletones_application/view_model/shared/nsf/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_application/view_model/shared/nsf/test_choices.py b/tests/unit/sampletones_application/view_model/shared/nsf/test_choices.py new file mode 100644 index 000000000..eb0e3f72f --- /dev/null +++ b/tests/unit/sampletones_application/view_model/shared/nsf/test_choices.py @@ -0,0 +1,186 @@ +from dataclasses import dataclass +from typing import Callable, Final + +import pytest + +from sampletones_application.view_model.shared.nsf.choices import NSFExportChoices +from sampletones_application.view_model.shared.nsf.offer import FIRST_FRAME +from sampletones_application.view_model.shared.nsf.repeat import NSFRepeat +from sampletones_core.constants.enums import ChannelName +from sampletones_core.project.project import Project +from sampletones_player.builder import SONG_START +from sampletones_player.compression.scheme import CompressionScheme +from sampletones_player.export.program import NSFProgram +from sampletones_player.nsf.information import NSFInformation, fit_field +from sampletones_player.specification.nsf import STRING_TEXT_SIZE +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase +from tests.suite.nsf import ( + PROGRAM_ARTIST, + PROGRAM_TITLE, + SAMPLE_CHANNELS, + project_offer, + sample_offer, + standing_choices, +) + +OVERLONG_TEXT: Final[str] = "Ünïcödé " * STRING_TEXT_SIZE +LATER_FRAME: Final[int] = 3 + + +class TestTheTextFitsItsField(BaseTestSuite): + """A typed text is held as the part of it the header field carries, so the dialog shows what + the file will list.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + edit: Callable[[NSFExportChoices, str], NSFExportChoices] + read: Callable[[NSFInformation], str] + + test_cases = ( + TestCase( + label="title", + edit=NSFExportChoices.with_title, + read=lambda information: information.title, + ), + TestCase( + label="artist", + edit=NSFExportChoices.with_artist, + read=lambda information: information.artist, + ), + TestCase( + label="copyright", + edit=NSFExportChoices.with_copyright, + read=lambda information: information.copyright, + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_an_overlong_text_is_cut_by_the_fields_rule(self, test_case: TestCase) -> None: + edited = test_case.edit(standing_choices(project_offer()), OVERLONG_TEXT) + + assert test_case.read(edited.information) == fit_field(OVERLONG_TEXT) + + def test_editing_one_field_keeps_the_others(self) -> None: + choices = standing_choices(project_offer()) + + edited = choices.with_copyright(PROGRAM_ARTIST) + + assert edited.information.title == PROGRAM_TITLE + assert edited.information.artist == PROGRAM_ARTIST + assert edited.information.copyright == PROGRAM_ARTIST + + +class TestAChannelIsTickedAmongTheSounded: + def test_unticking_a_channel_rests_it(self) -> None: + offer = project_offer() + + edited = standing_choices(offer).with_channel(ChannelName.NOISE, False, offer) + + assert ChannelName.NOISE not in edited.channels + + def test_ticking_a_channel_sounds_it_again(self) -> None: + offer = project_offer() + choices = standing_choices(offer).with_channel(ChannelName.NOISE, False, offer) + + edited = choices.with_channel(ChannelName.NOISE, True, offer) + + assert edited.channels == frozenset(offer.channels) + + def test_a_channel_the_source_leaves_silent_stays_unticked(self) -> None: + offer = sample_offer() + + edited = standing_choices(offer).with_channel(ChannelName.NOISE, True, offer) + + assert edited.channels == frozenset(SAMPLE_CHANNELS) + + def test_choices_with_no_channel_write_no_program(self) -> None: + offer = sample_offer() + choices = standing_choices(offer) + for channel in SAMPLE_CHANNELS: + choices = choices.with_channel(channel, False, offer) + + assert not choices.writable + + +class TestTheLoopFrameStaysInTheOrder(BaseTestSuite): + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + frame: int + + test_cases = ( + TestCase(label="before_the_first", frame=FIRST_FRAME - 1, expected=FIRST_FRAME), + TestCase(label="within", frame=LATER_FRAME, expected=LATER_FRAME), + TestCase(label="past_the_last", frame=project_offer().frame_count, expected=project_offer().last_frame), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_frame_is_brought_within_the_order(self, test_case: TestCase) -> None: + offer = project_offer() + + edited = standing_choices(offer).with_loop_frame(test_case.frame, offer) + + assert edited.loop_frame == test_case.expected + + +class TestTheSourceBoundsTheChoices: + def test_a_song_laid_out_in_frames_repeats_from_one(self) -> None: + offer = project_offer() + + edited = standing_choices(offer).with_repeat(NSFRepeat.FROM_FRAME, offer) + + assert edited.repeat == NSFRepeat.FROM_FRAME + + def test_a_reconstruction_keeps_its_repeat_off_the_frames(self) -> None: + offer = sample_offer() + + edited = standing_choices(offer).with_repeat(NSFRepeat.FROM_FRAME, offer) + + assert edited.repeat == NSFRepeat.FROM_START + + def test_a_project_compresses_with_its_instruments(self) -> None: + offer = project_offer() + + edited = standing_choices(offer).with_scheme(CompressionScheme.INSTRUMENTS, offer) + + assert edited.scheme == CompressionScheme.INSTRUMENTS + + def test_a_reconstruction_keeps_to_the_schemes_that_write_it_differently(self) -> None: + offer = sample_offer() + choices = standing_choices(offer) + + edited = choices.with_scheme(CompressionScheme.INSTRUMENTS, offer) + + assert edited.scheme == choices.scheme + + +class TestTheDialogOpensOnTheStatedProgram: + def test_a_project_opens_on_the_program_it_is_written_as(self) -> None: + program = NSFProgram.for_project(Project.create(title=PROGRAM_TITLE, author=PROGRAM_ARTIST)) + + choices = NSFExportChoices.initial(program, project_offer()) + + assert choices.information == program.information + assert choices.channels == program.channels + assert choices.repeat == NSFRepeat.FROM_START + assert choices.scheme == program.scheme + + def test_the_channels_narrow_to_the_ones_the_source_sounds(self) -> None: + program = NSFProgram.for_project(Project.create()) + + choices = NSFExportChoices.initial(program, sample_offer()) + + assert choices.channels == frozenset(SAMPLE_CHANNELS) + + def test_a_program_stopping_at_its_end_opens_playing_once(self) -> None: + program = NSFProgram.for_project(Project.create()).model_copy(update={"loop_tick": None}) + + choices = NSFExportChoices.initial(program, sample_offer()) + + assert choices.repeat == NSFRepeat.ONCE + + def test_a_program_repeating_from_later_in_the_song_is_refused(self) -> None: + program = NSFProgram.for_project(Project.create()).model_copy(update={"loop_tick": SONG_START + 1}) + + with pytest.raises(ValueError): + NSFExportChoices.initial(program, project_offer()) diff --git a/tests/unit/sampletones_application/view_model/shared/nsf/test_offer.py b/tests/unit/sampletones_application/view_model/shared/nsf/test_offer.py new file mode 100644 index 000000000..8e13e8afa --- /dev/null +++ b/tests/unit/sampletones_application/view_model/shared/nsf/test_offer.py @@ -0,0 +1,17 @@ +from sampletones_application.view_model.shared.nsf.offer import FIRST_FRAME +from sampletones_application.view_model.shared.nsf.repeat import NSFRepeat +from tests.suite.nsf import ORDER_FRAMES, project_offer, sample_offer + + +class TestTheRepeatsFollowTheFrames: + def test_a_song_laid_out_in_frames_repeats_from_any_of_them(self) -> None: + assert NSFRepeat.FROM_FRAME in project_offer().repeats + + def test_a_song_without_frames_repeats_from_its_start_or_plays_once(self) -> None: + assert sample_offer().repeats == (NSFRepeat.ONCE, NSFRepeat.FROM_START) + + def test_the_last_frame_is_the_orders_last(self) -> None: + assert project_offer().last_frame == ORDER_FRAMES - 1 + + def test_a_song_without_frames_returns_to_the_first(self) -> None: + assert sample_offer().last_frame == FIRST_FRAME diff --git a/tests/unit/sampletones_application/view_model/shared/nsf/test_view.py b/tests/unit/sampletones_application/view_model/shared/nsf/test_view.py new file mode 100644 index 000000000..f1eb4629e --- /dev/null +++ b/tests/unit/sampletones_application/view_model/shared/nsf/test_view.py @@ -0,0 +1,68 @@ +from pathlib import Path +from typing import Final + +from sampletones_application.view_model.shared.nsf.choices import NSFExportChoices +from sampletones_application.view_model.shared.nsf.offer import NSFExportOffer +from sampletones_application.view_model.shared.nsf.repeat import NSFRepeat +from sampletones_application.view_model.shared.nsf.view import NSFExportViewModel +from sampletones_core.constants.enums import ChannelName +from sampletones_player.nsf.information import field_size +from sampletones_player.specification.nsf import STRING_TEXT_SIZE +from tests.suite.nsf import SAMPLE_CHANNELS, project_offer, sample_offer, standing_choices + +DESTINATION: Final[Path] = Path("song.nsf") +SONG_TICKS: Final[int] = 10800 +NES_FREQUENCY: Final[int] = 60 +LENGTH_TEMPLATE: Final[str] = "{seconds}|{ticks}|{rate}" +SIZE_TEMPLATE: Final[str] = "{used}|{room}" + + +def export_view(offer: NSFExportOffer, choices: NSFExportChoices) -> NSFExportViewModel: + return NSFExportViewModel( + offer=offer, + choices=choices, + destination=DESTINATION, + ticks=SONG_TICKS, + nes_frequency=NES_FREQUENCY, + ) + + +class TestTheSetupShowsTheChoices: + def test_the_frame_is_asked_for_once_the_repeat_returns_to_one(self) -> None: + offer = project_offer() + choices = standing_choices(offer) + + assert not export_view(offer, choices).loop_frame_visible + assert export_view(offer, choices.with_repeat(NSFRepeat.FROM_FRAME, offer)).loop_frame_visible + + def test_the_program_is_written_while_a_channel_sounds(self) -> None: + offer = sample_offer() + choices = standing_choices(offer) + silent = choices + for channel in SAMPLE_CHANNELS: + silent = silent.with_channel(channel, False, offer) + + assert export_view(offer, choices).export_enabled + assert not export_view(offer, silent).export_enabled + + def test_a_channel_takes_a_tick_where_the_source_sounds_it(self) -> None: + offer = sample_offer() + view = export_view(offer, standing_choices(offer)) + + assert view.channel_offered(ChannelName.TRIANGLE) + assert not view.channel_offered(ChannelName.NOISE) + + def test_the_length_states_the_seconds_the_ticks_and_the_rate(self) -> None: + offer = project_offer() + view = export_view(offer, standing_choices(offer)) + + seconds = SONG_TICKS / NES_FREQUENCY + assert view.length_label(LENGTH_TEMPLATE) == f"{seconds}|{SONG_TICKS}|{NES_FREQUENCY}" + + def test_a_field_states_the_bytes_its_text_takes_against_the_room(self) -> None: + offer = project_offer() + view = export_view(offer, standing_choices(offer)) + + label = view.text_size_label(view.choices.information.title, SIZE_TEMPLATE) + + assert label == f"{field_size(view.choices.information.title)}|{STRING_TEXT_SIZE}" diff --git a/tests/unit/sampletones_core/timing/test_song.py b/tests/unit/sampletones_core/timing/test_song.py new file mode 100644 index 000000000..280e430f2 --- /dev/null +++ b/tests/unit/sampletones_core/timing/test_song.py @@ -0,0 +1,41 @@ +from typing import Final + +import pytest + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.performance import song_instructions +from sampletones_core.project.project import Project +from sampletones_core.project.settings import ProjectSettings +from sampletones_core.timing import SongTiming + +ROWS_PER_PATTERN: Final[int] = 4 +FRAMES: Final[int] = 3 + + +@pytest.fixture(name="project") +def project_fixture() -> Project: + project = Project.create( + rows_per_pattern=ROWS_PER_PATTERN, + settings=ProjectSettings(tempo=150, speed=5, nes_frequency=60), + ) + for _ in range(FRAMES - project.song.order_length()): + project.song.append_frame() + + return project + + +class TestFrameTick: + """The tick an order frame starts on, read from the groove each frame lasts.""" + + def test_the_first_frame_starts_the_song(self, project: Project) -> None: + assert SongTiming.from_project(project).frame_tick(0) == 0 + + def test_each_frame_starts_one_groove_after_the_one_before(self, project: Project) -> None: + timing = SongTiming.from_project(project) + groove = timing.groove().total_ticks + assert [timing.frame_tick(frame) for frame in range(FRAMES)] == [groove * frame for frame in range(FRAMES)] + + def test_the_frame_past_the_last_starts_where_the_walk_ends(self, project: Project) -> None: + """The song's length and the loop point a frame names are read from one rule.""" + walked = len(song_instructions(project)[ChannelName.PULSE1]) + assert SongTiming.from_project(project).frame_tick(project.song.order_length()) == walked diff --git a/tests/unit/sampletones_player/compression/test_scheme.py b/tests/unit/sampletones_player/compression/test_scheme.py new file mode 100644 index 000000000..53ce37a8c --- /dev/null +++ b/tests/unit/sampletones_player/compression/test_scheme.py @@ -0,0 +1,46 @@ +from typing import Tuple + +import pytest + +from sampletones_player.compression.options import EVERY_LAYER, CodecOptions +from sampletones_player.compression.scheme import CompressionScheme, offered_schemes + + +def layers(options: CodecOptions) -> Tuple[bool, ...]: + return (options.holds, options.phrases, options.transposition, options.search) + + +class TestCompressionScheme: + """Each scheme keeps the layers of the one before it and adds its own.""" + + def test_the_lightest_scheme_spells_every_value_out(self) -> None: + assert not any(layers(CompressionScheme.NONE.options)) + + def test_the_hardest_scheme_switches_every_layer_on(self) -> None: + assert CompressionScheme.SEARCH.options == EVERY_LAYER + + @pytest.mark.parametrize( + ("lighter", "harder"), + list(zip(list(CompressionScheme)[:-1], list(CompressionScheme)[1:])), + ids=str, + ) + def test_a_harder_scheme_keeps_every_layer_of_the_lighter_one( + self, + lighter: CompressionScheme, + harder: CompressionScheme, + ) -> None: + pairs = list(zip(layers(lighter.options), layers(harder.options))) + assert all(on_harder for on_lighter, on_harder in pairs if on_lighter) + assert layers(lighter.options) != layers(harder.options) + + +class TestOfferedSchemes: + """A song is offered the schemes that write it differently.""" + + def test_a_seeded_song_is_offered_every_scheme(self) -> None: + assert offered_schemes(seeded=True) == tuple(CompressionScheme) + + def test_a_song_seeding_nothing_is_offered_every_scheme_but_the_instruments(self) -> None: + assert offered_schemes(seeded=False) == tuple( + scheme for scheme in CompressionScheme if scheme != CompressionScheme.INSTRUMENTS + ) diff --git a/tests/unit/sampletones_player/compression/test_seeds.py b/tests/unit/sampletones_player/compression/test_seeds.py index 75ed651a2..619de74d6 100644 --- a/tests/unit/sampletones_player/compression/test_seeds.py +++ b/tests/unit/sampletones_player/compression/test_seeds.py @@ -2,7 +2,7 @@ import pytest -from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.enums import ALL_CHANNELS, ChannelName from sampletones_core.project.project import Project from sampletones_core.timers.utils import get_timer_table from sampletones_player.compression.pitch import PitchTable @@ -39,7 +39,7 @@ def slice_planes(project: Project) -> ChannelPlanes: def _offered(project: Project) -> Tuple[bytes, ...]: - return tuple(phrase.body for phrase in phrases_from_project(project, TUNING)) + return tuple(phrase.body for phrase in phrases_from_project(project, TUNING, ALL_CHANNELS)) class TestTheInstrumentsSeedTheDictionary: @@ -65,4 +65,10 @@ def test_a_plane_holding_one_value_offers_the_dictionary_nothing( def test_a_project_holding_no_sample_offers_nothing(self, project: Project) -> None: project.voices.clear() - assert phrases_from_project(project, TUNING) == () + assert phrases_from_project(project, TUNING, ALL_CHANNELS) == () + + def test_a_slice_on_a_channel_the_song_leaves_out_offers_nothing(self, project: Project) -> None: + """The slice plays on a channel the song rests, so no row of the song ever names it.""" + sounding = ALL_CHANNELS - {ChannelName.PULSE1} + assert _offered(project) + assert phrases_from_project(project, TUNING, sounding) == () diff --git a/tests/unit/sampletones_player/compression/test_song.py b/tests/unit/sampletones_player/compression/test_song.py index 133055fda..78b38b23a 100644 --- a/tests/unit/sampletones_player/compression/test_song.py +++ b/tests/unit/sampletones_player/compression/test_song.py @@ -3,6 +3,7 @@ import pytest from sampletones_player.compression.dictionary.phrase import Phrase +from sampletones_player.compression.scheme import CompressionScheme from sampletones_player.compression.song import compress_song, decompress_song from sampletones_player.registers.streams import ChannelStreams from tests.suite.player import ( @@ -27,7 +28,7 @@ def played( streams: ChannelStreams, - loop_tick: Optional[int] = None, + loop_tick: Optional[int], ) -> ChannelStreams: return decompress_song( compress_song( @@ -35,6 +36,7 @@ def played( PLAYER_PITCHES, seeds=NO_SEEDS, loop_tick=loop_tick, + scheme=CompressionScheme.SEARCH, ), PLAYER_PITCHES, ) @@ -45,14 +47,14 @@ class TestTheSongPlaysBackTheRegistersItWasCompressedFrom: def test_every_tick_writes_the_registers_it_was_given(self) -> None: streams = resting_streams((SOUNDING, OCTAVE_UP, RESTING)) - rebuilt = played(streams) + rebuilt = played(streams, None) assert [rebuilt.at(tick) for tick in range(streams.ticks)] == [ streams.at(tick) for tick in range(streams.ticks) ] def test_a_channel_running_out_early_is_carried_to_the_songs_length(self) -> None: streams = resting_streams((SOUNDING, OCTAVE_UP, RESTING)) - rebuilt = played(streams) + rebuilt = played(streams, None) assert len(rebuilt.noise) == streams.ticks assert set(rebuilt.noise) == {streams.noise[0]} @@ -68,18 +70,20 @@ def test_the_tick_a_song_returns_to_costs_the_streams_a_token(self) -> None: PLAYER_PITCHES, seeds=NO_SEEDS, loop_tick=MIDDLE_TICK, + scheme=CompressionScheme.SEARCH, ) played_once = compress_song( streams, PLAYER_PITCHES, seeds=NO_SEEDS, loop_tick=None, + scheme=CompressionScheme.SEARCH, ) assert looped.size > played_once.size def test_the_song_writes_the_same_registers_either_way(self) -> None: streams = resting_streams((SOUNDING,) * HELD_TICKS) - assert played(streams, MIDDLE_TICK) == played(streams) + assert played(streams, MIDDLE_TICK) == played(streams, None) class TestAPlaneNamesPitchesTheTableSounds: @@ -93,4 +97,5 @@ def test_a_timer_no_pitch_sounds_is_refused(self) -> None: PLAYER_PITCHES, seeds=NO_SEEDS, loop_tick=None, + scheme=CompressionScheme.SEARCH, ) diff --git a/tests/unit/sampletones_player/export/__init__.py b/tests/unit/sampletones_player/export/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_player/test_export.py b/tests/unit/sampletones_player/export/test_backend.py similarity index 77% rename from tests/unit/sampletones_player/test_export.py rename to tests/unit/sampletones_player/export/test_backend.py index 3736dfb90..5c2889b82 100644 --- a/tests/unit/sampletones_player/test_export.py +++ b/tests/unit/sampletones_player/export/test_backend.py @@ -1,10 +1,10 @@ import struct from pathlib import Path -from typing import Final +from typing import Final, FrozenSet, Optional import pytest -from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.enums import ALL_CHANNELS, ChannelName from sampletones_core.exports.backend import ExportBackend from sampletones_core.exports.format import ExportFormat from sampletones_core.exports.progress import ExportProgress @@ -18,17 +18,21 @@ from sampletones_core.project.project import Project from sampletones_core.project.settings import ProjectSettings from sampletones_core.timing import SongTiming -from sampletones_player.builder import SONG_START, song_from_project +from sampletones_player.builder import SONG_START +from sampletones_player.compression.scheme import CompressionScheme from sampletones_player.driver.image import DriverImage -from sampletones_player.export import NSFBackend +from sampletones_player.export.backend import NSFBackend +from sampletones_player.export.program import NSFProgram +from sampletones_player.nsf.information import NSFInformation from sampletones_player.specification.nsf import ( ARTIST_OFFSET, + COPYRIGHT_OFFSET, HEADER_SIZE, PROGRAM_SIZE, STRING_FIELD_SIZE, TITLE_OFFSET, ) -from sampletones_player.specification.song import LOOP_TICK_OFFSET +from sampletones_player.specification.song import LOOP_TICK_OFFSET, NO_LOOP, TOTAL_TICKS_OFFSET from sampletones_shared.exceptions import OperationCanceled, SongTooLargeError from sampletones_shared.paths.extensions import EXT_FILE_NSF from tests.suite.performance import ( @@ -56,6 +60,10 @@ ROWS_PER_PATTERN: Final[int] = 4 WITHDRAWN_WHILE_WALKING: Final[int] = 1 WITHDRAWN_WHILE_COMPRESSING: Final[int] = 2 +CHOSEN_TITLE: Final[str] = "Rainy Day Theme" +CHOSEN_ARTIST: Final[str] = "J. Kowalski" +CHOSEN_COPYRIGHT: Final[str] = "2026 J. Kowalski" +REPEATED_TICKS: Final[int] = 48 def lead_slice(name: str, frames: int) -> InstrumentExport: @@ -109,10 +117,15 @@ def bass_slice(name: str, frames: int) -> InstrumentExport: ) -def written_loop_tick(data: bytes) -> int: - """The tick a written program comes round to, read out of the song block behind the driver.""" +def song_word(data: bytes, offset: int) -> int: + """A word of a written program's song header, read out of the block behind the driver.""" block = data[HEADER_SIZE + len(DriverImage.load().code) :] - return int(struct.unpack_from(" int: + """The tick a written program comes round to.""" + return song_word(data, LOOP_TICK_OFFSET) def read_field(data: bytes, offset: int) -> str: @@ -121,7 +134,7 @@ def read_field(data: bytes, offset: int) -> str: @pytest.fixture(name="backend") def backend_fixture() -> NSFBackend: - return NSFBackend() + return NSFBackend.stated() class TestSeam: @@ -276,9 +289,8 @@ def test_the_program_plays_the_song_the_project_arranges( project = drum_project() destination = tmp_path / FILENAME backend.write_project(destination, ProjectExport(project=project)) - groove = SongTiming.from_project(project).groove() - expected = project.song.order_length() * groove.total_ticks - assert song_from_project(project, SONG_START).ticks == expected + expected = SongTiming.from_project(project).frame_tick(project.song.order_length()) + assert song_word(destination.read_bytes(), TOTAL_TICKS_OFFSET) == expected def test_the_program_repeats_from_its_first_tick( self, @@ -328,8 +340,7 @@ def test_the_walk_counts_the_ticks_the_song_lasts( backend.write_project(tmp_path / FILENAME, ProjectExport(project=project), reporter) walked = [report for report in reporter.reports if report.stage == ExportStage.WALKING] - groove = SongTiming.from_project(project).groove() - expected = project.song.order_length() * groove.total_ticks + expected = SongTiming.from_project(project).frame_tick(project.song.order_length()) assert walked assert [report.total for report in walked] == [expected] * len(walked) assert walked[-1].completed == expected @@ -412,3 +423,87 @@ def test_a_run_withdrawn_mid_compression_writes_nothing( assert reporter.last.stage == ExportStage.COMPRESSING assert not destination.exists() + + +def chosen_program( + *, + channels: FrozenSet[ChannelName], + loop_tick: Optional[int], + scheme: CompressionScheme, +) -> NSFProgram: + return NSFProgram( + information=NSFInformation( + title=CHOSEN_TITLE, + artist=CHOSEN_ARTIST, + copyright=CHOSEN_COPYRIGHT, + ), + channels=channels, + loop_tick=loop_tick, + scheme=scheme, + ) + + +def repeated_sample() -> SampleExport: + """A reconstruction holding one note, which every scheme past the first writes as a run.""" + return player_sample( + SAMPLE_NAME, + (lead_slice("lead", REPEATED_TICKS), bass_slice("bass", REPEATED_TICKS)), + nes_frequency=NTSC_FREQUENCY, + ) + + +class TestAChosenProgram: + """A program the user settled before the run is the one the file carries.""" + + @pytest.mark.parametrize( + ("offset", "expected"), + [ + (TITLE_OFFSET, CHOSEN_TITLE), + (ARTIST_OFFSET, CHOSEN_ARTIST), + (COPYRIGHT_OFFSET, CHOSEN_COPYRIGHT), + ], + ids=["title", "artist", "copyright"], + ) + def test_the_file_is_listed_under_the_chosen_text( + self, + backend: NSFBackend, + tmp_path: Path, + offset: int, + expected: str, + ) -> None: + destination = tmp_path / FILENAME + program = chosen_program(channels=ALL_CHANNELS, loop_tick=SONG_START, scheme=CompressionScheme.SEARCH) + backend.choosing(program).write_project(destination, ProjectExport(project=drum_project())) + assert read_field(destination.read_bytes(), offset) == expected + + def test_a_song_chosen_to_play_once_stops_at_its_end(self, backend: NSFBackend, tmp_path: Path) -> None: + destination = tmp_path / FILENAME + program = chosen_program(channels=ALL_CHANNELS, loop_tick=None, scheme=CompressionScheme.SEARCH) + backend.choosing(program).write_project(destination, ProjectExport(project=drum_project())) + assert written_loop_tick(destination.read_bytes()) == NO_LOOP + + def test_a_reconstruction_repeats_where_the_program_says(self, backend: NSFBackend, tmp_path: Path) -> None: + """The slices play once, and the program asks for them to come round all the same.""" + destination = tmp_path / FILENAME + program = chosen_program(channels=ALL_CHANNELS, loop_tick=SONG_START, scheme=CompressionScheme.SEARCH) + backend.choosing(program).write_sample(destination, repeated_sample()) + assert written_loop_tick(destination.read_bytes()) == SONG_START + + def test_a_lighter_scheme_writes_a_larger_file(self, backend: NSFBackend, tmp_path: Path) -> None: + spelled_out = tmp_path / "none.nsf" + runs = tmp_path / "runs.nsf" + for destination, scheme in ((spelled_out, CompressionScheme.NONE), (runs, CompressionScheme.RUNS)): + program = chosen_program(channels=ALL_CHANNELS, loop_tick=None, scheme=scheme) + backend.choosing(program).write_sample(destination, repeated_sample()) + + assert len(spelled_out.read_bytes()) > len(runs.read_bytes()) + + def test_choosing_a_program_leaves_the_backend_it_came_from_as_it_was( + self, + backend: NSFBackend, + tmp_path: Path, + ) -> None: + destination = tmp_path / FILENAME + backend.choosing(chosen_program(channels=ALL_CHANNELS, loop_tick=None, scheme=CompressionScheme.NONE)) + backend.write_project(destination, ProjectExport(project=drum_project())) + assert read_field(destination.read_bytes(), TITLE_OFFSET) == PROJECT_TITLE diff --git a/tests/unit/sampletones_player/export/test_program.py b/tests/unit/sampletones_player/export/test_program.py new file mode 100644 index 000000000..c40dc3bc2 --- /dev/null +++ b/tests/unit/sampletones_player/export/test_program.py @@ -0,0 +1,101 @@ +from typing import Final + +import pytest +from pydantic import ValidationError + +from sampletones_core.constants.enums import ALL_CHANNELS, ChannelName +from sampletones_core.exports.request import InstrumentExport, SampleExport +from sampletones_core.project.project import Project +from sampletones_player.builder import SONG_START, loop_tick_from_instruments +from sampletones_player.compression.scheme import CompressionScheme +from sampletones_player.export.program import DEFAULT_SCHEME, NO_ARTIST, NSFProgram +from sampletones_player.nsf.information import NSFInformation +from sampletones_shared.application import SAMPLETONES_COPYRIGHT +from tests.suite.player import ( + PLAYER_REFERENCE_PITCH, + player_features, + player_instrument, + player_sample, +) + +NTSC_FREQUENCY: Final[int] = 60 +SOUNDING_TICKS: Final[int] = 6 +PROJECT_TITLE: Final[str] = "Demo" +PROJECT_AUTHOR: Final[str] = "Jakim" +SAMPLE_NAME: Final[str] = "Amen" + + +def lead(loop: bool) -> InstrumentExport: + return player_instrument( + "lead", + ChannelName.PULSE1, + player_features(SOUNDING_TICKS, PLAYER_REFERENCE_PITCH, duty_cycle=True), + nes_frequency=NTSC_FREQUENCY, + loop=loop, + ) + + +def sample(loop: bool) -> SampleExport: + return player_sample(SAMPLE_NAME, (lead(loop),), nes_frequency=NTSC_FREQUENCY) + + +@pytest.fixture(name="project") +def project_fixture() -> Project: + return Project.create(title=PROJECT_TITLE, author=PROJECT_AUTHOR) + + +class TestTheProgramAProjectStates: + """What a composition is written as when nobody chose otherwise.""" + + def test_it_is_listed_under_the_projects_title_and_author(self, project: Project) -> None: + assert NSFProgram.for_project(project).information == NSFInformation( + title=PROJECT_TITLE, + artist=PROJECT_AUTHOR, + copyright=SAMPLETONES_COPYRIGHT, + ) + + def test_it_sounds_every_channel(self, project: Project) -> None: + assert NSFProgram.for_project(project).channels == ALL_CHANNELS + + def test_it_repeats_from_its_first_tick(self, project: Project) -> None: + assert NSFProgram.for_project(project).loop_tick == SONG_START + + def test_it_is_compressed_under_the_default_scheme(self, project: Project) -> None: + assert NSFProgram.for_project(project).scheme == DEFAULT_SCHEME + + +class TestTheProgramASampleStates: + """What a reconstruction's slices are written as when nobody chose otherwise.""" + + def test_it_is_listed_under_the_reconstructions_name_credited_to_nobody(self) -> None: + information = NSFProgram.for_sample(sample(loop=False)).information + assert (information.title, information.artist) == (SAMPLE_NAME, NO_ARTIST) + + def test_it_sounds_every_channel(self) -> None: + assert NSFProgram.for_sample(sample(loop=False)).channels == ALL_CHANNELS + + @pytest.mark.parametrize("loop", [True, False], ids=["repeating", "played once"]) + def test_it_repeats_where_every_slice_repeats(self, loop: bool) -> None: + request = sample(loop) + assert NSFProgram.for_sample(request).loop_tick == loop_tick_from_instruments(request.instruments) + + +class TestWhatAProgramHolds: + """A program states choices a file can carry.""" + + def test_a_program_sounding_no_channel_is_refused(self, project: Project) -> None: + with pytest.raises(ValidationError): + NSFProgram.model_validate(NSFProgram.for_project(project).model_dump() | {"channels": frozenset()}) + + def test_a_loop_before_the_song_starts_is_refused(self, project: Project) -> None: + with pytest.raises(ValidationError): + NSFProgram.model_validate(NSFProgram.for_project(project).model_dump() | {"loop_tick": -1}) + + def test_a_program_may_play_once(self, project: Project) -> None: + program = NSFProgram.model_validate(NSFProgram.for_project(project).model_dump() | {"loop_tick": None}) + assert program.loop_tick is None + + @pytest.mark.parametrize("scheme", list(CompressionScheme), ids=str) + def test_every_scheme_is_a_choice(self, project: Project, scheme: CompressionScheme) -> None: + program = NSFProgram.model_validate(NSFProgram.for_project(project).model_dump() | {"scheme": scheme}) + assert program.scheme == scheme diff --git a/tests/unit/sampletones_player/nsf/test_header.py b/tests/unit/sampletones_player/nsf/test_header.py index c8a0fbd84..2a4ec7097 100644 --- a/tests/unit/sampletones_player/nsf/test_header.py +++ b/tests/unit/sampletones_player/nsf/test_header.py @@ -41,6 +41,7 @@ SONG_COUNT, SONG_COUNT_OFFSET, STRING_FIELD_SIZE, + STRING_TEXT_SIZE, TITLE_OFFSET, VERSION_OFFSET, ) @@ -149,10 +150,11 @@ def test_the_field_carries_its_text(self, test_case: TestCase) -> None: field = read_string(header(), test_case.offset) assert field == test_case.expected.encode("utf-8").ljust(STRING_FIELD_SIZE, b"\x00") - def test_text_longer_than_the_field_is_written_as_much_as_fits(self) -> None: + def test_text_longer_than_the_field_is_written_as_much_as_fits_before_its_terminator(self) -> None: + """A player reads each field up to its NUL, so the field keeps a byte for one.""" overlong = "A" * (STRING_FIELD_SIZE * 2) data = header_to_bytes(NSFInformation(title=overlong, artist=ARTIST), ADDRESSES) - assert read_string(data, TITLE_OFFSET) == overlong.encode("utf-8")[:STRING_FIELD_SIZE] + assert read_string(data, TITLE_OFFSET) == overlong.encode("utf-8")[:STRING_TEXT_SIZE] + b"\x00" def test_the_fields_stand_back_to_back(self) -> None: assert (ARTIST_OFFSET - TITLE_OFFSET, COPYRIGHT_OFFSET - ARTIST_OFFSET) == ( diff --git a/tests/unit/sampletones_player/nsf/test_information.py b/tests/unit/sampletones_player/nsf/test_information.py new file mode 100644 index 000000000..a8a9cfed7 --- /dev/null +++ b/tests/unit/sampletones_player/nsf/test_information.py @@ -0,0 +1,58 @@ +from dataclasses import dataclass +from typing import Final + +import pytest + +from sampletones_player.nsf.information import TEXT_ENCODING, NSFInformation, field_size, fit_field +from sampletones_player.specification.nsf import STRING_FIELD_SIZE, STRING_TEXT_SIZE +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + +WIDE_CHARACTER: Final[str] = "ü" +SHORT_TEXT: Final[str] = "Rainy Day Theme" + + +class TestFitField(BaseTestSuite): + """The part of a text a header string field holds in front of its terminator.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + text: str + expected: str + + test_cases = ( + TestCase(label="short text", text=SHORT_TEXT, expected=SHORT_TEXT), + TestCase(label="empty text", text="", expected=""), + TestCase(label="exactly the room", text="A" * STRING_TEXT_SIZE, expected="A" * STRING_TEXT_SIZE), + TestCase(label="one byte over", text="A" * STRING_FIELD_SIZE, expected="A" * STRING_TEXT_SIZE), + TestCase( + label="a character straddling the edge", + text="A" * (STRING_TEXT_SIZE - 1) + WIDE_CHARACTER, + expected="A" * (STRING_TEXT_SIZE - 1), + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label) + def test_the_field_holds_the_text_that_fits(self, test_case: TestCase) -> None: + assert fit_field(test_case.text) == test_case.expected + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label) + def test_the_text_leaves_room_for_the_terminator(self, test_case: TestCase) -> None: + assert len(fit_field(test_case.text).encode(TEXT_ENCODING)) < STRING_FIELD_SIZE + + +class TestFieldSize: + def test_a_wide_character_takes_the_bytes_it_is_encoded_in(self) -> None: + assert field_size(SHORT_TEXT + WIDE_CHARACTER) == len(SHORT_TEXT) + len(WIDE_CHARACTER.encode(TEXT_ENCODING)) + + def test_a_fitted_text_takes_the_field_at_most(self) -> None: + assert field_size(fit_field(WIDE_CHARACTER * STRING_FIELD_SIZE)) <= STRING_TEXT_SIZE + + +class TestNSFInformation: + """Each text field is held as the part of itself the header carries.""" + + def test_every_field_is_held_as_it_fits(self) -> None: + overlong = "B" * STRING_FIELD_SIZE + information = NSFInformation(title=overlong, artist=overlong, copyright=overlong) + assert {information.title, information.artist, information.copyright} == {fit_field(overlong)} diff --git a/tests/unit/sampletones_player/test_builder.py b/tests/unit/sampletones_player/test_builder.py index bf04231de..a45e4a72e 100644 --- a/tests/unit/sampletones_player/test_builder.py +++ b/tests/unit/sampletones_player/test_builder.py @@ -1,9 +1,9 @@ -from typing import Dict, Final, List +from typing import Dict, Final, List, Optional import pytest -from sampletones_core.constants.enums import ChannelName -from sampletones_core.exports.request import InstrumentExport +from sampletones_core.constants.enums import ALL_CHANNELS, ChannelName +from sampletones_core.exports.request import InstrumentExport, SampleExport from sampletones_core.instructions import ( InstructionUnion, NoiseInstruction, @@ -24,7 +24,9 @@ streams_from_instructions, ) from sampletones_player.clock.schedule import PlaySchedule +from sampletones_player.compression.scheme import CompressionScheme from sampletones_player.registers.channel import channel_registers +from sampletones_player.song import Song from sampletones_player.specification.registers import ( TRIANGLE_COUNTER_CONTROL, TRIANGLE_SOUNDING_RELOAD, @@ -124,25 +126,28 @@ class TestSongFromReconstruction: def test_the_song_lasts_the_ticks_its_longest_channel_covers(self) -> None: reconstruction = player_reconstruction(one_channel(ChannelName.PULSE1), NTSC_FREQUENCY) - assert song_from_reconstruction(reconstruction, loop_tick=None).ticks == SOUNDING_TICKS + 1 + assert ( + song_from_reconstruction(reconstruction, loop_tick=None, scheme=CompressionScheme.SEARCH).ticks + == SOUNDING_TICKS + 1 + ) def test_the_schedule_follows_the_rate_the_reconstruction_was_built_at(self) -> None: reconstruction = player_reconstruction(one_channel(ChannelName.PULSE1), HALF_RATE_FREQUENCY) - song = song_from_reconstruction(reconstruction, loop_tick=None) + song = song_from_reconstruction(reconstruction, loop_tick=None, scheme=CompressionScheme.SEARCH) assert song.schedule == PlaySchedule.from_parameters(HALF_RATE_FREQUENCY) def test_the_song_carries_the_loop_it_is_given(self) -> None: reconstruction = player_reconstruction(one_channel(ChannelName.PULSE1), NTSC_FREQUENCY) - assert song_from_reconstruction(reconstruction, loop_tick=0).loop_tick == 0 + assert song_from_reconstruction(reconstruction, loop_tick=0, scheme=CompressionScheme.SEARCH).loop_tick == 0 def test_a_loop_beyond_the_songs_ticks_raises(self) -> None: reconstruction = player_reconstruction(one_channel(ChannelName.PULSE1), NTSC_FREQUENCY) with pytest.raises(ValueError): - song_from_reconstruction(reconstruction, loop_tick=SOUNDING_TICKS + 1) + song_from_reconstruction(reconstruction, loop_tick=SOUNDING_TICKS + 1, scheme=CompressionScheme.SEARCH) def test_the_timers_come_from_the_reconstructions_own_configuration(self) -> None: reconstruction = player_reconstruction(one_channel(ChannelName.PULSE1), NTSC_FREQUENCY) - song = song_from_reconstruction(reconstruction, loop_tick=None) + song = song_from_reconstruction(reconstruction, loop_tick=None, scheme=CompressionScheme.SEARCH) timer = get_timer_table(reconstruction.config.tuning)[PLAYER_REFERENCE_PITCH] assert (song.streams.pulse1[0].timer_low, song.streams.pulse1[0].timer_high) == (timer & 0xFF, timer >> 8) @@ -156,17 +161,27 @@ def test_a_retuned_reconstruction_plays_retuned_timers(self) -> None: update={"config": reconstruction.config.model_copy(update={"library": library})} ) timer = get_timer_table(retuned.config.tuning)[PLAYER_REFERENCE_PITCH] - song = song_from_reconstruction(retuned, loop_tick=None) + song = song_from_reconstruction(retuned, loop_tick=None, scheme=CompressionScheme.SEARCH) assert timer > PLAYER_TIMER_TABLE[PLAYER_REFERENCE_PITCH] assert (song.streams.pulse1[0].timer_low, song.streams.pulse1[0].timer_high) == (timer & 0xFF, timer >> 8) def test_a_reconstruction_describing_no_frame_plays_one_resting_tick(self) -> None: reconstruction = player_reconstruction({ChannelName.PULSE1: [silent_pulse()]}, NTSC_FREQUENCY) - song = song_from_reconstruction(reconstruction, loop_tick=None) + song = song_from_reconstruction(reconstruction, loop_tick=None, scheme=CompressionScheme.SEARCH) assert song.ticks == 1 +def sample_song(request: SampleExport) -> Song: + """The song a request plays as on every channel, compressed as hard as an export reaches.""" + return song_from_sample( + request, + channels=ALL_CHANNELS, + loop_tick=None, + scheme=CompressionScheme.SEARCH, + ) + + class TestInstructionsFromInstruments: """An export request's channel slices read back as the instructions the console sounds.""" @@ -205,23 +220,36 @@ class TestSongFromSample: """An export request read as the song the console plays it as.""" def test_every_slice_sounds_on_the_channel_it_was_reconstructed_for(self) -> None: - song = song_from_sample( - player_sample("demo", (lead(loop=False), bass(loop=False)), nes_frequency=NTSC_FREQUENCY) - ) + song = sample_song(player_sample("demo", (lead(loop=False), bass(loop=False)), nes_frequency=NTSC_FREQUENCY)) assert len(song.streams.pulse1) == SOUNDING_TICKS + 1 assert song.streams.triangle[0].linear_counter == TRIANGLE_COUNTER_CONTROL | TRIANGLE_SOUNDING_RELOAD assert set(song.streams.pulse2) == {channel_registers(ChannelName.PULSE2, {}, PLAYER_TIMER_TABLE)[0]} + def test_a_slice_on_a_channel_the_song_leaves_out_rests(self) -> None: + song = song_from_sample( + player_sample("demo", (lead(loop=False), bass(loop=False)), nes_frequency=NTSC_FREQUENCY), + channels=frozenset({ChannelName.PULSE1}), + loop_tick=None, + scheme=CompressionScheme.SEARCH, + ) + assert set(song.streams.triangle) == {channel_registers(ChannelName.TRIANGLE, {}, PLAYER_TIMER_TABLE)[0]} + assert len(song.streams.pulse1) == SOUNDING_TICKS + 1 + def test_the_schedule_follows_the_rate_the_request_states(self) -> None: - song = song_from_sample(player_sample("demo", (lead(loop=False),), nes_frequency=HALF_RATE_FREQUENCY)) + song = sample_song(player_sample("demo", (lead(loop=False),), nes_frequency=HALF_RATE_FREQUENCY)) assert song.schedule == PlaySchedule.from_parameters(HALF_RATE_FREQUENCY) - def test_a_request_whose_slices_repeat_loops(self) -> None: - song = song_from_sample(player_sample("demo", (lead(loop=True),), nes_frequency=NTSC_FREQUENCY)) + def test_the_song_carries_the_loop_it_is_given(self) -> None: + song = song_from_sample( + player_sample("demo", (lead(loop=True),), nes_frequency=NTSC_FREQUENCY), + channels=ALL_CHANNELS, + loop_tick=SONG_START, + scheme=CompressionScheme.SEARCH, + ) assert song.loop_tick == SONG_START def test_the_timers_come_from_the_tuning_the_request_carries(self) -> None: - song = song_from_sample(player_sample("demo", (lead(loop=False),), nes_frequency=NTSC_FREQUENCY)) + song = sample_song(player_sample("demo", (lead(loop=False),), nes_frequency=NTSC_FREQUENCY)) timer = get_timer_table(Tuning())[PLAYER_REFERENCE_PITCH] assert (song.streams.pulse1[0].timer_low, song.streams.pulse1[0].timer_high) == (timer & 0xFF, timer >> 8) @@ -230,7 +258,7 @@ def test_a_retuned_request_plays_retuned_timers(self) -> None: pitch plays the divider that concert pitch names. """ tuning = Tuning(a4_frequency=RETUNED_A4_FREQUENCY) - song = song_from_sample(player_sample("demo", (lead(loop=False),), nes_frequency=NTSC_FREQUENCY, tuning=tuning)) + song = sample_song(player_sample("demo", (lead(loop=False),), nes_frequency=NTSC_FREQUENCY, tuning=tuning)) timer = get_timer_table(tuning)[PLAYER_REFERENCE_PITCH] assert (song.streams.pulse1[0].timer_low, song.streams.pulse1[0].timer_high) == (timer & 0xFF, timer >> 8) @@ -259,36 +287,75 @@ def drum_project() -> Project: return project +def project_song(project: Project, loop_tick: Optional[int]) -> Song: + """The song a project plays as on every channel, compressed as hard as an export reaches.""" + return song_from_project( + project, + channels=ALL_CHANNELS, + loop_tick=loop_tick, + scheme=CompressionScheme.SEARCH, + ) + + class TestSongFromProject: """A whole project reaching the console as the four streams the driver plays.""" def test_the_song_lasts_the_ticks_the_projects_groove_gives_its_rows(self) -> None: project = drum_project() - song = song_from_project(project, None) - groove = SongTiming.from_project(project).groove() - assert song.ticks == project.song.order_length() * groove.total_ticks + song = project_song(project, loop_tick=None) + assert song.ticks == SongTiming.from_project(project).frame_tick(project.song.order_length()) def test_the_schedule_follows_the_rate_the_project_states(self) -> None: - song = song_from_project(drum_project(), None) + song = project_song(drum_project(), loop_tick=None) assert song.schedule == PlaySchedule.from_parameters(SONG_SETTINGS.nes_frequency) def test_every_channel_carries_the_songs_whole_length(self) -> None: - song = song_from_project(drum_project(), None) + song = project_song(drum_project(), loop_tick=None) assert len(song.streams.noise) == song.ticks assert len(song.streams.triangle) == song.ticks def test_a_pitch_reaches_the_timer_the_tuning_names(self) -> None: - song = song_from_project(drum_project(), None) + song = project_song(drum_project(), loop_tick=None) timer = get_timer_table(Tuning())[PLAYER_REFERENCE_PITCH] assert (song.streams.pulse1[0].timer_low, song.streams.pulse1[0].timer_high) == (timer & 0xFF, timer >> 8) def test_the_song_carries_the_loop_it_is_given(self) -> None: - song = song_from_project(drum_project(), SONG_START) + song = project_song(drum_project(), loop_tick=SONG_START) assert song.loop_tick == SONG_START def test_a_frame_the_order_plays_twice_lasts_twice_as_long(self) -> None: project = drum_project() - one_frame = song_from_project(project, None).ticks + one_frame = project_song(project, loop_tick=None).ticks project.song.append_frame() project.song.set_order_entry(1, ChannelName.PULSE1, 0) - assert song_from_project(project, None).ticks == 2 * one_frame + assert project_song(project, loop_tick=None).ticks == 2 * one_frame + + def test_a_channel_the_song_leaves_out_rests_throughout(self) -> None: + project = drum_project() + song = song_from_project( + project, + channels=ALL_CHANNELS - {ChannelName.PULSE1}, + loop_tick=None, + scheme=CompressionScheme.SEARCH, + ) + silent = channel_registers(ChannelName.PULSE1, {}, PLAYER_TIMER_TABLE)[0] + assert song.ticks == project_song(project, loop_tick=None).ticks + assert set(song.streams.pulse1) == {silent} + + +class TestEveryCompressionSchemePlaysTheSameSong: + """A scheme decides how the song is written, and the console plays the same registers under each.""" + + @pytest.mark.parametrize("scheme", list(CompressionScheme), ids=str) + def test_the_song_writes_the_registers_the_hardest_scheme_writes(self, scheme: CompressionScheme) -> None: + project = drum_project() + song = song_from_project(project, channels=ALL_CHANNELS, loop_tick=SONG_START, scheme=scheme) + assert song.streams == project_song(project, loop_tick=SONG_START).streams + + def test_a_harder_scheme_takes_no_more_room(self) -> None: + project = drum_project() + sizes = [ + song_from_project(project, channels=ALL_CHANNELS, loop_tick=SONG_START, scheme=scheme).planes.size + for scheme in CompressionScheme + ] + assert sizes == sorted(sizes, reverse=True) diff --git a/tests/unit/sampletones_player/test_instrument_song.py b/tests/unit/sampletones_player/test_instrument_song.py index b3250fef4..769aad1da 100644 --- a/tests/unit/sampletones_player/test_instrument_song.py +++ b/tests/unit/sampletones_player/test_instrument_song.py @@ -1,6 +1,6 @@ from typing import Final, Tuple -from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.enums import ALL_CHANNELS, ChannelName from sampletones_core.features.envelope import Envelope from sampletones_core.project.patterns.row import Row from sampletones_core.project.project import Project @@ -8,6 +8,7 @@ from sampletones_core.project.voices.instrument import Instrument from sampletones_core.project.voices.note_on import NoteOn from sampletones_player.builder import song_from_project +from sampletones_player.compression.scheme import CompressionScheme ROWS_PER_PATTERN: Final[int] = 4 VOLUME: Final[Tuple[int, ...]] = (15, 12, 9) @@ -33,17 +34,17 @@ class TestAnInstrumentReachesTheConsole: """The player reads the same walk the sequencer plays, so an instrument needs nothing of its own.""" def test_a_project_holding_an_instrument_compiles(self) -> None: - song = song_from_project(_project(), loop_tick=None) + song = song_from_project(_project(), channels=ALL_CHANNELS, loop_tick=None, scheme=CompressionScheme.SEARCH) assert song.planes.ticks > 0 def test_the_compiled_song_sounds_the_instrument_on_the_channel_it_was_placed_on(self) -> None: - song = song_from_project(_project(), loop_tick=None) + song = song_from_project(_project(), channels=ALL_CHANNELS, loop_tick=None, scheme=CompressionScheme.SEARCH) levels = [registers.control & VOLUME_NIBBLE for registers in song.streams.pulse1[: len(VOLUME)]] assert levels == list(VOLUME) def test_the_channels_it_was_not_placed_on_stay_silent(self) -> None: - song = song_from_project(_project(), loop_tick=None) + song = song_from_project(_project(), channels=ALL_CHANNELS, loop_tick=None, scheme=CompressionScheme.SEARCH) assert {registers.control & VOLUME_NIBBLE for registers in song.streams.pulse2} == {0}