diff --git a/CHANGELOG.md b/CHANGELOG.md index a44206cf..ef699221 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # SampleToNES +## v0.3.1 [2026-07-31] + +* Added support to [Bitphase](https://github.com/paator/bitphase). +* Fixed arpeggio editing shifting a sample's pitch permanently. +* Bumped the reconstruction data-version to `2.1`. + ## v0.3.0 [2026-07-31] * Added a _Sequencer_ view with FamiTracker-style patterns. diff --git a/README.md b/README.md index ba05c6df..9997d6ac 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ It supports: * `pulse2` * `triangle` * `noise` -* exporting reconstructed audio as FamiTracker `.fti` instruments or as `.wav` +* exporting reconstructed audio as FamiTracker `.fti` instruments, Bitphase `.json` instrument presets, or `.wav` ## Installation diff --git a/conftest.py b/conftest.py new file mode 100644 index 00000000..1fee4a4c --- /dev/null +++ b/conftest.py @@ -0,0 +1,38 @@ +import importlib.util +from pathlib import Path +from typing import Final, Optional, Tuple + +JEEPNEY_MODULE: Final[str] = "jeepney" + +PORTAL_PATHS: Final[Tuple[str, ...]] = ( + "src/sampletones_application/utils/file_dialogs/backends/portal", + "tests/unit/sampletones_application/utils/file_dialogs/backends/portal", + "tests/unit/sampletones_application/utils/file_dialogs/test_selection.py", +) + +PORTAL_LIBRARY_INSTALLED: Final[bool] = importlib.util.find_spec(JEEPNEY_MODULE) is not None + + +def pytest_ignore_collect(collection_path: Path) -> Optional[bool]: + """ + Keeps collection to the modules the running platform imports. + + ``jeepney`` is declared for Linux alone, so what speaks to the desktop portal is collected + where that library is installed. The behaviour those modules describe belongs to the Linux + desktop, and the Linux runs of the suite cover it. + + Args: + collection_path: The file or directory pytest is about to look into. + + Returns: + Optional[bool]: ``True`` for a path that stays out of collection, ``None`` to leave the + choice with pytest. + """ + if PORTAL_LIBRARY_INSTALLED: + return None + + root = Path(__file__).parent + if any(collection_path.is_relative_to(root / path) for path in PORTAL_PATHS): + return True + + return None diff --git a/docs/development/architecture.md b/docs/development/architecture.md index 2a8d4e8f..2214d5b6 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -102,7 +102,9 @@ A new exclusive operation joins by contributing its `is_active` to the authority Where behaviour depends on the operating system, the desktop environment, or an external command-line tool, that variation is expressed as a `Protocol` with one implementation per target, chosen by a runtime factory — never as platform branches scattered through the callers. The factory probes availability (`shutil.which`) and environment (`System.current()`, `XDG_CURRENT_DESKTOP`) and returns the implementation that fits; callers depend only on the Protocol and read identically on every platform. -`utils/file_dialogs/` applies this to native file dialogs: a `FileDialogBackend` Protocol with `kdialog`, `zenity`, and `tkinter` implementations, selected by `select_file_dialog_backend()`. Each tool's quirks stay sealed inside its own implementation — `kdialog` activates the supplied filter, `zenity` lists the filter but leaves the selector on its "(None)" default because its command line offers no way to pre-select one — and the guarantee callers depend on, that a saved file carries the configured extension, is enforced once in the API layer above every backend. `sampletones_core/calibration/referee/` follows the same shape with its `build_referees()` factory. +`utils/file_dialogs/` applies this to native file dialogs: a `FileDialogBackend` Protocol in `protocol.py`, with desktop-portal, `kdialog`, `zenity`, and `tkinter` implementations under `backends/`, selected by `select_file_dialog_backend()`. Each tool's quirks stay sealed inside its own implementation — the portal lists every offered type in its selector, reports the one the user picked, and is told which window a dialog belongs to, since the desktop draws it in another process, `kdialog` activates a single filter, `zenity` lists the filter but leaves the selector on its "(None)" default because its command line offers no way to pre-select one — and the guarantee callers depend on, that a saved file carries one of the offered extensions, is enforced once in the API layer above every backend. `sampletones_core/calibration/referee/` follows the same shape with its `build_referees()` factory. + +Ordering the implementations is part of the factory's job: where several are available, the one that expresses the most wins. A save offering several file types is answered by the portal because it alone reports which type was chosen, so an export names its format in the type selector; a backend answering with a name alone leaves the extension to be read from the name, and the API layer settles it either way. ### 12. One dispatcher owns the keyboard @@ -283,7 +285,7 @@ There are two coordinator kinds: | `categories/` | `LanguageManager` and the `Page / Panel / TextType / Element` enum hierarchy used as lookup keys | | `layout/` | Pydantic models loaded from YAML at startup; injected into coordinators and panels as `LayoutConfig` | | `constants/` | DPG widget tags (`TAG_*`) and tag suffix fragments (`SUF_*`) | -| `utils/` | dpg-free helpers usable by any layer (`utils/callbacks/`, colour, threading, and `utils/file_dialogs/` — OS-native file dialogs behind a `FileDialogBackend` Protocol). DPG-bound helpers live in `utils/gui/` and are off-limits to the non-visual layers | +| `utils/` | dpg-free helpers usable by any layer (`utils/callbacks/`, colour, threading, and `utils/file_dialogs/` — OS-native file dialogs behind a `FileDialogBackend` Protocol, with the D-Bus desktop-portal client under `utils/file_dialogs/backends/portal/`). DPG-bound helpers live in `utils/gui/` and are off-limits to the non-visual layers | | `viewport.py` | Manages DPG viewport geometry and fullscreen state | --- diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 590c519a..f181a0af 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -3,12 +3,15 @@ ### Navigation * Interface scale +* VSync/frame rate options * Tree navigation using keys * Waveform LOD for zooming * Keybindings options * Tracker cell shortcuts * Drag and drop * Multiple Reconstruction views +* Playing a fragment by clicking on a waveform +* Transpose/note pitch display duality ### Tracker @@ -25,6 +28,7 @@ * Theme selector and palette management * In-application guide/tutorial +* Language selector ### Technical @@ -32,6 +36,7 @@ * Code documentation (docstrings) * Backward compatibility: library/reconstruction upgrade scheme * Respecting FamiTracker limitations +* Carrying the project comment and tempo into a Bitphase document, once the format holds them * Per-tab undo routing ## Bugs diff --git a/docs/development/dependencies.md b/docs/development/dependencies.md index c0c61698..5c09edac 100644 --- a/docs/development/dependencies.md +++ b/docs/development/dependencies.md @@ -18,10 +18,16 @@ See [GPU acceleration](../guide/installation.md#gpu-acceleration) for enabling i Instruction libraries and reconstructions are serialized with [MessagePack](https://msgpack.org/) (the `msgpack` package). No external compiler or system dependency is required — it is installed automatically with the package. +## File dialogs + +Dialogs open through the XDG desktop portal (`org.freedesktop.portal.FileChooser`), reached over D-Bus with the pure-Python `jeepney` package on Linux. The portal lists every offered file type in its selector and reports back the one the user picked, which is what lets a save settle its format from the type chosen there. Where no portal answers, `kdialog` and `zenity` take over, and Tk last. + +`jeepney` is declared for Linux alone, so the modules that speak to the portal are imported where it is installed: the application probes for it before reaching them, and the root `conftest.py` keeps them out of collection elsewhere, leaving the Linux runs of the suite to cover them. + ## Linux (standalone executable) Building a standalone executable on Linux needs the PortAudio, Tk and OpenGL/X11 system packages. Install them with `make system-deps` (or run `scripts/linux/build/dependencies.sh`), which holds the full list. -PortAudio is required. Tk backs the file dialogs where `kdialog` and `zenity` are absent, and `make release` requires it so the shipped executable stays self-contained. +PortAudio is required. Tk backs the file dialogs where neither a portal nor a desktop tool answers, and `make release` requires it so the shipped executable stays self-contained. The executable links against the glibc of the machine that builds it and runs on that version or newer, so a redistributable artifact belongs on the oldest Debian or Ubuntu release being supported. diff --git a/docs/formats/bitphase.md b/docs/formats/bitphase.md new file mode 100644 index 00000000..256757e9 --- /dev/null +++ b/docs/formats/bitphase.md @@ -0,0 +1,209 @@ +# Bitphase export format + +This document is the reference for how _SampleToNES_ writes +[Bitphase](https://github.com/paator/bitphase) files. It describes the two files the +`sampletones_core.formats.bitphase` package produces — the `.btp` document and the +`.json` instrument preset — and the Bitphase capacity limits the exporter respects. +Read it before changing anything under `formats/bitphase/`; the sibling +[FamiTracker export](famitracker.md) document covers the other tracker. + +The target is Bitphase's **NES (2A03) chip**: five channels (two squares, triangle, +noise, DPCM), with the DPCM channel always silent by design. Every constant referenced +here has a named counterpart under `sampletones_core/formats/bitphase/specification/` +(grouped by unit: `chip`, `channels`, `instruments`, `patterns`). + +Bitphase plays a note by three columns acting together, and that shapes the whole +mapping: an **instrument** supplies the per-tick register values, a **table** supplies +the per-tick pitch movement, and the **note column** supplies the pitch they move +around. A reconstruction's volume and duty envelopes become the instrument, its +arpeggio envelope becomes the table, and its reference pitch becomes the note. + +## A. File formats + +### A.1 `.btp` — the document + +A `.btp` is the document's JSON under gzip — no header and no version field. The +exporter writes it without separator padding and with a fixed gzip timestamp, so +exporting an unchanged document twice yields identical bytes. Written by +`formats/bitphase/btp.py`. + +Bitphase's loader reads each field on its own and falls back to a default for any it +misses, so a document that carries every field below loads exactly as it was written. + +``` +Project { name, author, songs[], loopPointId, patternOrder[], tables[], + patternOrderColors{}, instruments[] } +Song { patterns[], tuningTable[], initialSpeed, chipType, chipVariant, + chipFrequency, interruptFrequency, a4TuningHz, virtualChannelMap{} } +Pattern { id, length, channels[], patternRows[] } +Channel { rows[], label } +Row { note: { name, octave }, effects[], instrument, table, volume } +Table { id, rows[], loop, name } +Instrument { id, chipType, rows[], loop, name } +``` + +Instruments and tables belong to the **project** rather than to a song, so every song +addresses the same lists. `patternOrder` names the pattern each order position plays, +and `loopPointId` is the order position playback returns to. + +**Field names are camelCase.** The Pydantic models under `formats/bitphase/model/` +carry snake_case attributes and serialize through a camelCase alias generator, so the +Python side reads like the rest of the codebase while the file reads like Bitphase's. + +### A.2 `.json` — the instrument preset + +Bitphase's instruments panel saves and loads a single instrument at runtime through a +file picker. The file holds `{ chipType, name, loop, rows }`, indented the way Bitphase +writes its own, so a preset written here reads like one saved from the tracker. Written +by `formats/bitphase/preset.py`. + +A preset carries rows alone, so its pitch movement rides in each row's `toneAdd` +(section C.3) rather than in a table. + +## B. The NES instrument + +An instrument advances **one row per engine tick** while a note sounds, so a row +carries every register value the channel takes for that tick. From +`formats/bitphase/model/instrument.py`, matching Bitphase's `NesInstrumentRow`: + +| Field | Range | Runtime meaning | What the exporter writes | +| --- | --- | --- | --- | +| `pulseWidth` | 0–3 | square duty cycle; on the noise channel, any nonzero value selects the short LFSR | the duty-cycle envelope item (squares), the short/long mode (noise), a flat value (triangle) | +| `volumeOrRate` | 0–15 | the literal channel volume while `envelope` stays off | the volume envelope item | +| `envelope` | bool | reads `volumeOrRate` as a hardware decay rate | `false`, so each item is the volume itself | +| `soundLength` | 0–511 | length counter in ticks; `0` holds the note | `0`, so the volume envelope alone shapes the note | +| `toneAdd` | −4096–4095 | period offset added to the tuning-table period (squares and triangle) | `0` in a document, the pitch contour in a preset | +| `toneAccumulation` | bool | sums `toneAdd` across ticks | `false`, since each item is an absolute offset | +| `retrigger` | bool | restarts the waveform phase this tick | `false`, so the waveform runs continuously | +| `sweep` / `sweepRate` / `sweepShift` | bool / 0–7 / −7–7 | the square channel's hardware sweep | disabled | + +**Looping.** Playback returns to the instrument's `loop` row once it runs off the end, +which is the only mode there is. A looping slice therefore sets `loop = 0` so its +envelopes repeat from the start while the note is held; a one-shot sets +`loop = len - 1`, and since the volume envelope ends on a note-off item, the +instrument rests in silence once it has played through. A sample's `loop` flag drives +this, the same flag the FamiTracker exporter reads. + +**Equal lengths.** Instrument rows and table rows advance on independent per-tick +counters, so they share a length and a loop point and stay in step for as long as the +note sounds. `equalize_lengths` in `exporters/lengths.py` supplies that shared length — +the same rule the FamiTracker exporter applies, with the item limit left unbounded +here (section D). + +## C. Pitch + +### C.1 The tuning table + +A song carries a 96-entry `tuningTable`, one channel period per note index, built by +`formats/bitphase/tuning.py` as a port of Bitphase's `generate12TETTuningTable`: + +``` +frequency = a4TuningHz * 2 ^ ((index - 45) / 12) +period = round(chipFrequency / 16 / frequency) clamped to 1..2047 +``` + +Rounding matches JavaScript's `Math.round` (half away from zero on positives), so a +table built here equals the one Bitphase derives from the same settings. The exporter +writes NTSC (1 789 773 Hz) at concert pitch; PAL (1 662 607 Hz) and Dendy +(1 773 448 Hz) are named in `specification/chip.py`. + +**A note index is the absolute pitch less 24**, which puts indices 0–95 over pitches +24–119 — the same span the FamiTracker exporter clamps to. A pattern cell stores that +index as a semitone and an octave, which playback resolves back with +`name - 2 + (octave - 1) * 12`. + +The triangle channel's period is written from the same table, so a written note sounds +an octave below — the convention SampleToNES and FamiTracker already share. + +### C.2 Tables carry the contour + +A table holds one semitone offset per tick, and playback adds `rows[position]` to the +channel's note every tick. That is a direct match for a reconstruction's arpeggio +envelope in absolute mode, so the contour crosses over verbatim on the pitched +channels. + +A pattern's `table` column names a table by `id + 1`; `0` leaves the attached table +alone and `-1` detaches it. + +**Noise** derives its period from the note index rather than from the tuning table: +playback reads `period = 15 - (index mod 16)`. Every period therefore repeats once per +sixteen indices, and the exporter picks a base index far enough below the top of the +table for a whole cycle of offsets to stay in range: + +``` +base index = 48 + ((15 - initial_period) mod 16) lands in 48..63 +table offset = (-arpeggio_step) mod 16 lands in 0..15 +``` + +so `15 - ((base + offset) mod 16)` is the period the reconstruction chose, wrapped into +the sixteen the channel holds. + +### C.3 Presets fold the contour into the period + +An instrument preset carries no table, so its pitch movement is expressed as the +per-tick `toneAdd` each row applies to the note's own period. The offsets are measured +against the pitch the slice was reconstructed at, under the tuning a freshly created +Bitphase document plays — NTSC at concert pitch. The noise channel takes its period +from the note, so its preset rows hold a flat offset. + +## D. What the exporter builds per scope + +A `.btp` holds a whole document, so every scope lands in one file; a preset holds one +instrument, so a reconstruction lands as a set of them beside the name the export was +given, one per slice. + +| Scope | `.btp` | `.json` preset | +| --- | --- | --- | +| One generator slice | a playable document holding that instrument | one file | +| A whole reconstruction | a playable document holding every slice | one file per slice, beside the chosen name | +| A project | the song, its samples and its arrangement | — | + +**Instrument and reconstruction documents are playable.** Each slice becomes an +instrument and the table that carries its contour, and one pattern triggers every slice +at row 0 on the channel it was reconstructed for, so opening the document and pressing +play sounds the reconstruction. The pattern is sized to cover the longest instrument, +and where one instrument outlasts a single pattern the order gains resting positions +until it has played through. + +**A project flattens its order.** A SampleToNES order frame points each channel at its +own pattern, where a Bitphase order position names one pattern spanning every channel. +Each frame therefore becomes a pattern of its own carrying that frame's channels side +by side, with `patternOrder = [0..n-1]`. The arrangement crosses over whole; it simply +shares fewer patterns. + +Row cells follow from the columns: an instrument command writes the note from +`initial_pitch + transpose`, the instrument number, the table column and the row's +volume; a note-off writes note name `1`; a blank line leaves every column alone. + +## E. Bitphase capacity limits + +| Quantity | Bitphase limit | Exporter behaviour | +| --- | --- | --- | +| Items per instrument row list | unbounded | writes the envelope whole | +| Rows per table | unbounded | writes the contour whole | +| Instruments | the instrument column holds 2 base-36 digits, so 1–1295 | raises past 1295 | +| Tables | the table column holds 1 base-36 digit, so ids 0–34 | raises past 35 tables | +| Note range | the 96-entry tuning table, pitch 24–119 | clamps to the nearest playable note | +| Pattern length (rows) | 1–256 | clamps the preview pattern; a project keeps `rows_per_pattern` | +| Order positions | unbounded | matches | +| Speed | 1–255 | written verbatim from settings | +| DPCM channel | present | emitted empty | + +Tables and instruments are numbered together — each slice takes one of each — so the +table column is what a wide document reaches first: 35 slices fit, and the exporter +raises rather than writing a document whose later voices cannot be named. + +## F. What does not cross over + +Three things the SampleToNES model holds have no counterpart in a Bitphase document, +and the exporter leaves them behind: + +- **`ProjectInfo.comment`** — a Bitphase project carries a name and an author only. +- **`ProjectSettings.tempo`** — Bitphase's engine is speed-only, so `initialSpeed` + carries `speed` and the tempo is left to the tick rate. +- **A volume column of `0`** — Bitphase reads it as "leave the volume alone", so a row + that asks for silence through the volume column alone reaches playback unchanged. + +`interruptFrequency` carries the reconstruction's own tick rate. Bitphase's settings +panel offers 50 and 60 Hz, and its loader and timeline accept any value, so a rate +outside that pair plays correctly while leaving that one selector unmatched. diff --git a/docs/formats/famitracker.md b/docs/formats/famitracker.md index 61a5fb88..ab90b250 100644 --- a/docs/formats/famitracker.md +++ b/docs/formats/famitracker.md @@ -1,7 +1,7 @@ # FamiTracker export format This document is the reference for how _SampleToNES_ writes FamiTracker files. It -describes the two binary formats the `sampletones_core.famitracker` package +describes the two binary formats the `sampletones_core.formats.famitracker` package produces — the `.fti` instrument file and the `.ftm` module file — and lists the FamiTracker capacity limits that the project domain model will grow to respect. @@ -12,7 +12,7 @@ noise, DPCM), with the DPCM channel and DPCM sample bank always empty by design. All multi-byte integers are **little-endian**. Field types below use `uint8`, `int8`, `uint32`, `int32`; strings are noted per field. Every constant referenced -here has a named counterpart under `sampletones_core/famitracker/specification/` +here has a named counterpart under `sampletones_core/formats/famitracker/specification/` (grouped by unit: `file`, `blocks`, `channels`, `sequences`, `instruments`, `patterns`, `parameters`), and every block has its own writer function so this specification is readable straight from the code. @@ -22,7 +22,7 @@ specification is readable straight from the code. ### A.1 `.fti` — instrument file An `.fti` holds a single 2A03 instrument: its five sequences inline, then an empty -DPCM section. Written by `sampletones_core/famitracker/fti.py`. +DPCM section. Written by `sampletones_core/formats/famitracker/instrument.py`. | Field | Type | Value | | --- | --- | --- | @@ -50,7 +50,7 @@ Each **sequence record**: ### A.2 `.ftm` — module file An `.ftm` is a file header followed by a sequence of named, versioned blocks and a -final `END` marker. Written by `sampletones_core/famitracker/ftm.py`, one function +final `END` marker. Written by `sampletones_core/formats/famitracker/module.py`, one function per block. **File header** @@ -164,13 +164,15 @@ and triggering the instrument at `initial_pitch` replays that contour. Volume, d (or noise mode) and any pitch sequences carry across directly. The DPCM key-assignment table is empty by design. -For the pitched channels, `center_pitches` picks the offset origin: it takes the -midpoint of the contour's `(lowest, highest)` range, reports that pitch as -`initial_pitch`, and stores each frame as `pitch − initial_pitch`. The offsets -straddle zero and stay compact around one note, and the pattern cell holds the -contour's midpoint — a rising contour prints its middle note and opens below it. The -noise channel measures its offsets from the first sounding period instead, wrapped -into the 16 available periods. +The offset origin is chosen once, when the reconstruction is built, and stored with it +as that channel's reference pitch (see +[Reconstructions](reconstructions.md#contents)). For the pitched channels +`center_pitch` picks it, taking the midpoint of the contour's `(lowest, highest)` +range; the noise channel takes the first sounding period. Every later export reports +that stored pitch as `initial_pitch` and writes each frame as `pitch − initial_pitch`, +wrapped into the 16 available periods on noise. The offsets straddle zero and stay +compact around one note, and the pattern cell holds the contour's midpoint — a rising +contour prints its middle note and opens below it. ## C. FamiTracker capacity limits diff --git a/docs/formats/reconstructions.md b/docs/formats/reconstructions.md index 236bd58f..7b455b98 100644 --- a/docs/formats/reconstructions.md +++ b/docs/formats/reconstructions.md @@ -29,7 +29,12 @@ A `.stn` file holds: one waveform per enabled channel (`pulse1`, `pulse2`, `triangle`, `noise`); * **per-channel instructions** — the instruction stream each channel plays, one [instruction](../glossary.md#instruction) per frame. This is the data a - FamiTracker export is built from. + FamiTracker export is built from; +* **per-channel reference pitch** — the note each channel's arpeggio offsets are + measured against, chosen once when the reconstruction is built and stored with + the instructions it describes. An export reads the offsets against this pitch, + so editing an arpeggio moves the frames around a base that stays put (see + [FamiTracker export](famitracker.md)). ## Detached reconstructions @@ -50,6 +55,6 @@ application version is stored alongside it, for reference. `.stn` files live in the documents folder. They are binary ([MessagePack](https://msgpack.org/)) with the audio arrays embedded, so a file -is self-contained. The instruction streams can be exported to FamiTracker — one +is self-contained. The instruction streams can be exported to a tracker — one instrument per channel, or a whole module — as described in -[FamiTracker export](famitracker.md). +[FamiTracker export](famitracker.md) and [Bitphase export](bitphase.md). diff --git a/docs/glossary.md b/docs/glossary.md index 307923de..d8dddcfa 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -161,6 +161,12 @@ A [_tracker application_](http://famitracker.com/) for composing music for the NES 2A03. _SampleToNES_ exports instruments and modules that it (and its forks) can load. +### Bitphase + +A [_web tracker_](https://github.com/paator/bitphase) whose chips include the NES +2A03. _SampleToNES_ exports documents and instrument presets it can load. See +[Bitphase export](formats/bitphase.md). + ### Tracker / sequencer A pattern-based music editor. _SampleToNES_'s built-in sequencer arranges @@ -185,6 +191,16 @@ The list that arranges patterns into the song's timeline. A complete FamiTracker song, saved as an `.ftm` file — its settings, instruments, patterns, and order together. +### Document + +A complete Bitphase project, saved as a `.btp` file — its songs, instruments, +tables, patterns, and order together. + +### Table + +In Bitphase, a per-tick list of semitone offsets a pattern cell attaches to a +channel, which carries the pitch contour a FamiTracker arpeggio sequence would. + ### Sample (sequencer) A reconstruction added to the sequencer as a playable, placeable voice in the @@ -194,6 +210,8 @@ song. A single FamiTracker instrument, saved as an `.fti` file, exported from one channel of a reconstruction. See [FamiTracker export](formats/famitracker.md). +Bitphase takes the same slice as a `.json` instrument preset. See +[Bitphase export](formats/bitphase.md). ## File types @@ -204,3 +222,5 @@ channel of a reconstruction. See [FamiTracker export](formats/famitracker.md). | `.stp` | [Project](formats/projects.md) — a bundle of reconstructions with a song and settings. | | `.fti` | FamiTracker instrument ([export](formats/famitracker.md)). | | `.ftm` | FamiTracker module ([export](formats/famitracker.md)). | +| `.btp` | Bitphase document ([export](formats/bitphase.md)). | +| `.json` | Bitphase instrument preset ([export](formats/bitphase.md)), or the [configuration file](formats/configuration.md). | diff --git a/docs/guide/files.md b/docs/guide/files.md index 78d5971c..06ec3d40 100644 --- a/docs/guide/files.md +++ b/docs/guide/files.md @@ -26,7 +26,29 @@ You can point the library and output folders elsewhere from the **Main** tab's | `.stp` | [project](../formats/projects.md) | `projects/` | | `.fti` | FamiTracker instrument (exported) | wherever you choose | | `.ftm` | FamiTracker module (exported) | wherever you choose | +| `.json` | Bitphase instrument preset (exported) | wherever you choose | +| `.btp` | Bitphase project (exported) | wherever you choose | The `.fti` and `.ftm` files are what you load into -[FamiTracker](../formats/famitracker.md); the other three are _SampleToNES_'s own -formats. +[FamiTracker](../formats/famitracker.md), and `.json` and `.btp` are what +[Bitphase](../formats/bitphase.md) reads; the rest are _SampleToNES_'s own formats. + +## Exported files + +The extension names the tracker an export is written for: `.fti` and `.ftm` go to +FamiTracker, `.json` and `.btp` to Bitphase. The save dialog offers the file types +that fit what you are exporting and fills in the extension of the type it is set to. +Exporting one channel offers both trackers, so switching the type there switches the +tracker; typing an extension yourself picks the tracker directly. + +What you name in the dialog also names what a tracker lists: + +| Export | You name | What is written | +| --- | --- | --- | +| **Instruments** panel ▸ **Export instrument...** | the file | that file, its instrument carrying the name you gave | +| **Reconstruction ▸ Export instruments** | the batch | one file per channel beside that name, each named ` (channel)` | +| **File ▸ Export** | the file | that file, holding the whole song | + +So exporting a `Kick` reconstruction to FamiTracker instruments writes +`Kick (pulse1).fti`, `Kick (triangle).fti`, and one file for every other channel the +reconstruction uses, all in the folder you chose. diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index 323ae93e..342022fc 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -18,8 +18,9 @@ instruments, and building a whole song. Both assume it is already runs. 5. When it finishes, click **Load** to open the result on the **Reconstructions** tab. -6. Click **Export FamiTracker instruments** and choose a folder. One `.fti` - instrument is written per channel. +6. Choose **Reconstruction ▸ Export instruments ▸ FamiTracker instruments...** and + name the export. One `.fti` file is generated per instrument: `Kick (pulse1).fti`, + `Kick (triangle).fti`, and so on. That is the shortest path from a sound to instruments you can load in FamiTracker. The [interface guide](interface.md) covers the **Main** and **Reconstructions** @@ -37,8 +38,8 @@ tabs in full. sample to a channel with the cell's right-click **Set instrument**. 5. Arrange the piece in the **Order** grid, and set **Rows**, **Tempo**, **Speed**, and **NES frequency** under **Module options**. -6. Choose **Export as FamiTracker module** (or **File ▸ Export FamiTracker - module...**) and pick a path for the `.ftm` file. +6. Choose **File ▸ Export ▸ FamiTracker module...** and pick a path for the `.ftm` + file. **Bitphase project...** beside it writes the same song as a `.btp`. The [sequencer guide](sequencer.md) covers the tracker grid, the order, samples, and undo history in full. diff --git a/docs/guide/interface.md b/docs/guide/interface.md index c0680f53..d9fb86d3 100644 --- a/docs/guide/interface.md +++ b/docs/guide/interface.md @@ -39,14 +39,20 @@ Open a saved reconstruction from the list on the left; if the current one has unsaved edits, you are asked whether to save it first. You can play it back and switch **Play audio source:** between **Reconstruction** and **Original audio** to compare the two, and **Locate original audio** re-links the source file if it has -moved. To get your results out, **Export FamiTracker instruments** writes one -`.fti` per channel, **Export reconstruction to WAV** renders the audio, and **Add -to Sequencer** sends the reconstruction into a song as a sample (see the +moved. + +To get your results out, **Reconstruction ▸ Export instruments** writes the +whole reconstruction as one file per channel — `.fti` under **FamiTracker +instruments...**, `.json` under **Bitphase presets...** — and **Reconstruction ▸ +Export to WAV...** renders the audio. **Add to Sequencer**, on a reconstruction's +right-click menu, sends it into a song as a sample (see the [sequencer guide](sequencer.md)). For finer control, the **Instruments** panel on the right shows each channel's instrument — its pitch, volume, arpeggio, and duty sequences — which you can edit -by dragging the bars or typing values, and export one channel at a time. +by dragging the bars or typing values. **Export instrument...** writes the channel +on show, for whichever tracker the save dialog's file type names — see +[where your files live](files.md#exported-files). ## Instructions diff --git a/docs/index.md b/docs/index.md index 5751f256..92a22ec4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,7 +2,8 @@ _SampleToNES_ approximates an audio sample using only the sound channels of the NES's 2A03 chip — two pulse waves, a triangle and noise — and lets you arrange -the results into a song and export them to [FamiTracker](glossary.md#famitracker). +the results into a song and export them to [FamiTracker](glossary.md#famitracker) +or [Bitphase](glossary.md#bitphase). This is the documentation for using it, understanding how it works, and building on it. @@ -41,6 +42,7 @@ The [**formats**](formats/) section documents the files _SampleToNES_ reads and - [Reconstructions](formats/reconstructions.md) — the `.stn` reconstruction data. - [Projects](formats/projects.md) — the `.stp` project bundle. - [FamiTracker export](formats/famitracker.md) — the `.fti` instrument and `.ftm` module formats. +- [Bitphase export](formats/bitphase.md) — the `.btp` document and `.json` instrument preset formats. - [Configuration file](formats/configuration.md) — the `config.json` structure. ## Programming with SampleToNES @@ -59,6 +61,7 @@ The [**development**](development/) section is for contributors. - [Coding guidelines](development/guidelines.md) — conventions for the codebase. - [Dependencies](development/dependencies.md) — the libraries _SampleToNES_ builds on. - [Bugs and to-dos](development/bugs-and-todos.md) — the working ledger of known gaps. +- [Bitphase integration status](development/bitphase-integration-status.md) — what the Bitphase export covers and what is left to verify. ## Glossary diff --git a/pyproject.toml b/pyproject.toml index 3ad02ec0..d1ccdc22 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sampletones" -version = "0.3.0" +version = "0.3.1" description = "Approximate audio samples with the NES 2A03 oscillators and export them as FamiTracker instruments" readme = "README.md" requires-python = ">=3.12" @@ -47,6 +47,7 @@ dependencies = [ "scipy>=1.13,<2", "screeninfo>=0.8,<0.9", "tqdm>=4.66,<5", + "jeepney>=0.8,<1; sys_platform == 'linux'", "pytaskbar>=0.1.1,<0.2; platform_system == 'Windows'", "pywin32>=306; platform_system == 'Windows'", "PyYAML>=6.0,<7", diff --git a/scripts/ci/check_version_tag.py b/scripts/ci/check_version_tag.py index 24896b38..47d4438c 100644 --- a/scripts/ci/check_version_tag.py +++ b/scripts/ci/check_version_tag.py @@ -17,9 +17,19 @@ def tag_names_version(*, tag: str, project_version: str) -> bool: def main(argv: Sequence[str]) -> int: """Confirm a release tag and the project metadata agree on the version being released.""" - parser = argparse.ArgumentParser(description="Compare a release tag against the project version.") - parser.add_argument("--tag", required=True, help="the release tag being built, such as v0.3.0") - parser.add_argument("--project-version", required=True, help="the version recorded in pyproject.toml") + parser = argparse.ArgumentParser( + description="Compare a release tag against the project version.", + ) + parser.add_argument( + "--tag", + required=True, + help="the release tag being built, such as v0.3.0", + ) + parser.add_argument( + "--project-version", + required=True, + help="the version recorded in pyproject.toml", + ) arguments = parser.parse_args(list(argv)) tag: str = arguments.tag diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 0a54caf3..1414563e 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Any, Final, Optional +from typing import Any, Dict, Final, Optional import dearpygui.dearpygui as dpg from pydantic import ValidationError @@ -104,6 +104,7 @@ open_file_dialog, select_directory_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.fps import FPSTimer from sampletones_application.utils.frame_limiter import FrameLimiter @@ -129,6 +130,9 @@ from sampletones_core.paths import EXT_FILES_AUDIO from sampletones_core.project.instruments.sample import Sample from sampletones_core.reconstructions import Reconstruction +from sampletones_core.trackers.backend import TrackerBackend +from sampletones_core.trackers.format import TrackerFormat +from sampletones_core.trackers.registry import build_tracker_backends from sampletones_core.types.feature import FeatureValue from sampletones_shared.application import ( SAMPLETONES_AUTHOR, @@ -207,6 +211,8 @@ def __init__( self.retune_service: SampleRetuneService = SampleRetuneService(priority=_priority) self.retune_service.subscribe(self._on_retune_result) + self.tracker_backends: Dict[TrackerFormat, TrackerBackend] = build_tracker_backends() + self.project_manager: ProjectManager = ProjectManager() self.project_controller: ProjectController = ProjectController(self.project_manager) self.history: HistoryManager = HistoryManager( @@ -267,6 +273,8 @@ def __init__( self.project_controller, self.project_manager, self.session_manager, + self.export_service, + tracker_backends=self.tracker_backends, dialogs=self.dialogs, language_manager=self.language_manager, on_tab_switch=self._set_current_tab, @@ -298,6 +306,7 @@ def __init__( reconstruction_manager=self.reconstruction_manager, browser_manager=self.browser_manager, export_service=self.export_service, + tracker_backends=self.tracker_backends, on_load_reconstruction_with_confirmation=self._reconstruction_coordinator.load_with_confirmation, on_reconstruct_file=self._reconstruct_file_dialog, on_reconstruct_directory=self._reconstruct_directory_dialog, @@ -471,7 +480,7 @@ def _create_shortcut_bindings(self) -> ShortcutBindings: save_project=self._project_coordinator.save, save_project_as=self._project_coordinator.save_as_dialog, project_properties=self._open_project_properties, - export_project_module=self._project_coordinator.export_module_dialog, + export_project=self._project_coordinator.export_project_dialog, close_project=self._project_coordinator.close_with_confirmation, exit=self._on_close, undo=self._sequencer_tab.undo, @@ -668,13 +677,17 @@ def _reconstruct_file_dialog(self) -> None: GlobalDialogTitleElements.RECONSTRUCT_FILE, ], initial_directory=self.session_manager.get_audio_input_path(), - extensions=EXT_FILES_AUDIO, - filter_name=self.language_manager[ - Page.GLOBAL, - Panel.DIALOG, - TextType.FILTER, - FileFilterElements.AUDIO, - ], + filters=( + FileFilter.for_extensions( + self.language_manager[ + Page.GLOBAL, + Panel.DIALOG, + TextType.FILTER, + FileFilterElements.AUDIO, + ], + EXT_FILES_AUDIO, + ), + ), ) self._handle_reconstruct_file(filepath) @@ -726,9 +739,9 @@ def _export_reconstruction_wav_dialog(self) -> None: if self._reconstruction_coordinator.check_loaded(): self._reconstructions_tab.request_export_wav_dialog() - def _export_reconstruction_instruments_dialog(self) -> None: + def _export_reconstruction_instruments_dialog(self, tracker_format: TrackerFormat) -> None: if self._reconstruction_coordinator.check_loaded(): - self._reconstructions_tab.request_export_instruments_dialog() + self._reconstructions_tab.request_export_instruments_dialog(tracker_format) def _reconstruct_file(self, filepath: Path) -> None: self._main_tab.set_input_path(filepath, convert=True) diff --git a/src/sampletones_application/categories/elements/global_.py b/src/sampletones_application/categories/elements/global_.py index 336192c9..7071a083 100644 --- a/src/sampletones_application/categories/elements/global_.py +++ b/src/sampletones_application/categories/elements/global_.py @@ -64,7 +64,9 @@ class MenuElements(AbstractElement): ITEM_FILE_SAVE_PROJECT = "item_file_save_project" ITEM_FILE_SAVE_PROJECT_AS = "item_file_save_project_as" ITEM_FILE_PROJECT_PROPERTIES = "item_file_project_properties" - ITEM_FILE_EXPORT_MODULE = "item_file_export_module" + GROUP_FILE_EXPORT = "group_file_export" + ITEM_FILE_EXPORT_FAMITRACKER = "item_file_export_famitracker" + ITEM_FILE_EXPORT_BITPHASE = "item_file_export_bitphase" ITEM_FILE_CLOSE_PROJECT = "item_file_close_project" ITEM_FILE_EXIT = "item_file_exit" GROUP_EDIT = "group_edit" @@ -80,7 +82,9 @@ class MenuElements(AbstractElement): ITEM_RECONSTRUCTION_SAVE_AS = "item_reconstruction_save_as" ITEM_RECONSTRUCTION_CLOSE = "item_reconstruction_close" ITEM_RECONSTRUCTION_EXPORT_WAV = "item_reconstruction_export_wav" - ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS = "item_reconstruction_export_instruments" + GROUP_RECONSTRUCTION_EXPORT_INSTRUMENTS = "group_reconstruction_export_instruments" + ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS_FAMITRACKER = "item_reconstruction_export_instruments_famitracker" + ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS_BITPHASE_PRESET = "item_reconstruction_export_instruments_bitphase_preset" GROUP_PLAYBACK = "group_playback" ITEM_PLAYBACK_PLAY = "item_playback_play" ITEM_PLAYBACK_PAUSE = "item_playback_pause" @@ -157,6 +161,8 @@ class GlobalMessageElements(AbstractElement): PROJECT_SAVE_FAILED = "project_save_failed" PROJECT_EXPORTED_SUCCESSFULLY = "project_exported_successfully" PROJECT_EXPORT_FAILED = "project_export_failed" + BITPHASE_PROJECT_EXPORTED_SUCCESSFULLY = "bitphase_project_exported_successfully" + BITPHASE_PROJECT_EXPORT_FAILED = "bitphase_project_export_failed" NEW_UNSAVED_PROJECT = "new_unsaved_project" OPEN_UNSAVED_PROJECT = "open_unsaved_project" CLOSE_UNSAVED_PROJECT = "close_unsaved_project" @@ -198,7 +204,8 @@ class GlobalDialogTitleElements(AbstractElement): SAVE_PROJECT = "save_project" PROJECT_SAVED = "project_saved" EXPORT_MODULE = "export_module" - MODULE_EXPORTED = "module_exported" + EXPORT_BITPHASE_PROJECT = "export_bitphase_project" + PROJECT_EXPORTED = "project_exported" NEW_UNSAVED_PROJECT = "new_unsaved_project" OPEN_UNSAVED_PROJECT = "open_unsaved_project" CLOSE_UNSAVED_PROJECT = "close_unsaved_project" @@ -213,7 +220,9 @@ class FileFilterElements(AbstractElement): PROJECT = "project" RECONSTRUCTION = "reconstruction" MODULE = "module" - INSTRUMENT = "instrument" + FAMITRACKER_INSTRUMENT = "famitracker_instrument" + BITPHASE_PROJECT = "bitphase_project" + BITPHASE_PRESET = "bitphase_preset" CONFIG = "config" AUDIO = "audio" WAVE = "wave" diff --git a/src/sampletones_application/categories/trackers.py b/src/sampletones_application/categories/trackers.py new file mode 100644 index 00000000..be1a789c --- /dev/null +++ b/src/sampletones_application/categories/trackers.py @@ -0,0 +1,66 @@ +from dataclasses import dataclass +from typing import Dict, Final, Tuple + +from sampletones_application.categories.elements.global_ import ( + FileFilterElements, + GlobalDialogTitleElements, + GlobalMessageElements, + MenuElements, +) +from sampletones_core.trackers.format import TrackerFormat + + +@dataclass(frozen=True) +class TrackerProjectElements: + """Which texts one tracker format's project export reads. + + Every format names its own file kind, so the dialog that picks a destination and the + one that reports the outcome speak in the words of the tracker that reads the file. + + Attributes: + dialog_title: Title of the dialog the destination is picked in. + filter_name: Name of the file filter the dialog offers. + exported_message: Shown when the project reaches its file. + export_failed_message: Shown when the export fails. + """ + + dialog_title: GlobalDialogTitleElements + filter_name: FileFilterElements + exported_message: GlobalMessageElements + export_failed_message: GlobalMessageElements + + +TRACKER_PROJECT_ELEMENTS: Final[Dict[TrackerFormat, TrackerProjectElements]] = { + TrackerFormat.FAMITRACKER: TrackerProjectElements( + dialog_title=GlobalDialogTitleElements.EXPORT_MODULE, + filter_name=FileFilterElements.MODULE, + exported_message=GlobalMessageElements.PROJECT_EXPORTED_SUCCESSFULLY, + export_failed_message=GlobalMessageElements.PROJECT_EXPORT_FAILED, + ), + TrackerFormat.BITPHASE: TrackerProjectElements( + dialog_title=GlobalDialogTitleElements.EXPORT_BITPHASE_PROJECT, + filter_name=FileFilterElements.BITPHASE_PROJECT, + exported_message=GlobalMessageElements.BITPHASE_PROJECT_EXPORTED_SUCCESSFULLY, + export_failed_message=GlobalMessageElements.BITPHASE_PROJECT_EXPORT_FAILED, + ), +} + +TRACKER_PROJECT_MENU_LABELS: Final[Dict[TrackerFormat, MenuElements]] = { + TrackerFormat.FAMITRACKER: MenuElements.ITEM_FILE_EXPORT_FAMITRACKER, + TrackerFormat.BITPHASE: MenuElements.ITEM_FILE_EXPORT_BITPHASE, +} + +INSTRUMENT_EXPORT_FORMATS: Final[Tuple[TrackerFormat, ...]] = ( + TrackerFormat.FAMITRACKER, + TrackerFormat.BITPHASE_PRESET, +) + +TRACKER_INSTRUMENT_FILTERS: Final[Dict[TrackerFormat, FileFilterElements]] = { + TrackerFormat.FAMITRACKER: FileFilterElements.FAMITRACKER_INSTRUMENT, + TrackerFormat.BITPHASE_PRESET: FileFilterElements.BITPHASE_PRESET, +} + +TRACKER_SAMPLE_MENU_LABELS: Final[Dict[TrackerFormat, MenuElements]] = { + TrackerFormat.FAMITRACKER: MenuElements.ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS_FAMITRACKER, + TrackerFormat.BITPHASE_PRESET: MenuElements.ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS_BITPHASE_PRESET, +} diff --git a/src/sampletones_application/coordinators/config.py b/src/sampletones_application/coordinators/config.py index d6db15bd..0b48f571 100644 --- a/src/sampletones_application/coordinators/config.py +++ b/src/sampletones_application/coordinators/config.py @@ -1,6 +1,6 @@ import json from pathlib import Path -from typing import Dict, Final +from typing import Dict, Final, Tuple import dearpygui.dearpygui as dpg from pydantic import ValidationError @@ -27,6 +27,7 @@ open_file_dialog, 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.dialogs import DialogsRenderer from sampletones_core.paths import EXT_FILE_JSON @@ -75,13 +76,7 @@ def save_dialog(self) -> None: ], initial_directory=self._session_manager.get_config_path(), default_filename=DEFAULT_CONFIG_FILENAME, - extensions=[EXT_FILE_JSON], - filter_name=self._language_manager[ - Page.GLOBAL, - Panel.DIALOG, - TextType.FILTER, - FileFilterElements.CONFIG, - ], + filters=self._config_filters(), ) self._handle_save(filepath) @@ -121,17 +116,25 @@ def load_dialog(self) -> None: GlobalDialogTitleElements.LOAD_CONFIG, ], initial_directory=self._session_manager.get_config_path(), - extensions=[EXT_FILE_JSON], - filter_name=self._language_manager[ - Page.GLOBAL, - Panel.DIALOG, - TextType.FILTER, - FileFilterElements.CONFIG, - ], + filters=self._config_filters(), ) self._handle_load(filepath) + def _config_filters(self) -> Tuple[FileFilter, ...]: + """The single type a configuration is written as and read from.""" + return ( + FileFilter.for_extensions( + self._language_manager[ + Page.GLOBAL, + Panel.DIALOG, + TextType.FILTER, + FileFilterElements.CONFIG, + ], + [EXT_FILE_JSON], + ), + ) + @ignore_none_path def _handle_load(self, filepath: Path) -> None: try: diff --git a/src/sampletones_application/coordinators/project.py b/src/sampletones_application/coordinators/project.py index e89d59ce..39b8d3a1 100644 --- a/src/sampletones_application/coordinators/project.py +++ b/src/sampletones_application/coordinators/project.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Optional +from typing import Dict, Optional, Tuple from sampletones_application.categories.abstract import AbstractElement from sampletones_application.categories.elements.global_ import ( @@ -10,9 +10,15 @@ ) from sampletones_application.categories.hierarchy import Page, Panel, Tab, TextType from sampletones_application.categories.manager import LanguageManager +from sampletones_application.categories.trackers import TRACKER_PROJECT_ELEMENTS from sampletones_application.config.managers.session import SessionManager from sampletones_application.logic.project.controller import ProjectController from sampletones_application.logic.project.manager import ProjectManager +from sampletones_application.services.export.error import ExportError +from sampletones_application.services.export.kind import ExportKind +from sampletones_application.services.export.result import ExportResult +from sampletones_application.services.export.service import ExportService +from sampletones_application.services.export.success import ExportSuccess from sampletones_application.tags.general import ( TAG_GLOBAL_DIALOG_MODULE_EXPORTED, TAG_GLOBAL_DIALOG_PROJECT_OPEN, @@ -23,11 +29,15 @@ open_file_dialog, 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.dialogs import DialogsRenderer -from sampletones_core.paths import EXT_FILE_MODULE, EXT_FILE_PROJECT +from sampletones_core.paths import EXT_FILE_PROJECT +from sampletones_core.trackers.backend import TrackerBackend +from sampletones_core.trackers.format import TrackerFormat +from sampletones_core.trackers.scope import ExportScope from sampletones_shared.constants.project import ( - DEFAULT_MODULE_FILENAME, + DEFAULT_EXPORT_NAME, DEFAULT_PROJECT_FILENAME, ) from sampletones_shared.exceptions import ( @@ -37,7 +47,7 @@ ) from sampletones_shared.logger import logger from sampletones_shared.types.callback import Callback, VoidCallback -from sampletones_shared.utils.system.paths import get_directory +from sampletones_shared.utils.system.paths import get_directory, get_filename class ProjectCoordinator: @@ -56,7 +66,9 @@ def __init__( project_controller: ProjectController, project_manager: ProjectManager, session_manager: SessionManager, + export_service: ExportService, *, + tracker_backends: Dict[TrackerFormat, TrackerBackend], dialogs: DialogsRenderer, language_manager: LanguageManager, on_tab_switch: Callback, @@ -65,11 +77,15 @@ def __init__( self._project_controller = project_controller self._project_manager = project_manager self._session_manager = session_manager + self._export_service = export_service + self._tracker_backends = tracker_backends self._dialogs = dialogs self._language_manager = language_manager self._on_tab_switch = on_tab_switch self._project_manager.session.on_state_changed = on_session_state_changed + export_service.subscribe(self._on_export_result) + @property def project_name(self) -> Optional[str]: name = self._project_manager.session.name @@ -162,38 +178,56 @@ def save_as_dialog(self) -> bool: title=self._title(GlobalDialogTitleElements.SAVE_PROJECT), initial_directory=directory, default_filename=filename, - extensions=[EXT_FILE_PROJECT], - filter_name=self._filter_name(FileFilterElements.PROJECT), + filters=self._project_filters(), ) return self._handle_save_as(filepath) - def _get_project_filename(self) -> str: - return f"{self.project_name}{EXT_FILE_MODULE}" if self.project_name else DEFAULT_MODULE_FILENAME + def _project_filters(self) -> Tuple[FileFilter, ...]: + """The single type a project of this application's own is written as and read from.""" + return ( + FileFilter.for_extensions( + self._filter_name(FileFilterElements.PROJECT), + [EXT_FILE_PROJECT], + ), + ) + + def _get_project_filename(self, extension: str) -> str: + name = self.project_name or DEFAULT_EXPORT_NAME + return get_filename(name, extension) - def export_module_dialog(self) -> None: + def export_project_dialog(self, tracker_format: TrackerFormat) -> None: + """Prompts for a destination and writes the open project in ``tracker_format``. + + Args: + tracker_format: The tracker the project is written for. + """ if not self._project_controller.is_open: return + backend = self._tracker_backends[tracker_format] + elements = TRACKER_PROJECT_ELEMENTS[tracker_format] + extension = backend.extension(ExportScope.PROJECT) path = self._session_manager.get_project_path() - filename = self._get_project_filename() - directory = get_directory(path) filepath = save_file_dialog( - title=self._title(GlobalDialogTitleElements.EXPORT_MODULE), - initial_directory=directory, - default_filename=filename, - extensions=[EXT_FILE_MODULE], - filter_name=self._filter_name(FileFilterElements.MODULE), + title=self._title(elements.dialog_title), + initial_directory=get_directory(path), + default_filename=self._get_project_filename(extension), + filters=( + FileFilter.for_extensions( + self._filter_name(elements.filter_name), + [extension], + ), + ), ) - self._handle_export_module(filepath) + self._handle_export_project(filepath, tracker_format) def _open_dialog(self) -> None: filepath = open_file_dialog( title=self._title(GlobalDialogTitleElements.OPEN_UNSAVED_PROJECT), initial_directory=self._session_manager.get_project_path(), - extensions=[EXT_FILE_PROJECT], - filter_name=self._filter_name(FileFilterElements.PROJECT), + filters=self._project_filters(), ) self._handle_open(filepath) @@ -209,8 +243,12 @@ def _handle_save_as(self, filepath: Path) -> bool: return self._save(filepath) @ignore_none_path - def _handle_export_module(self, filepath: Path) -> None: - self._export_module(filepath) + def _handle_export_project(self, filepath: Path, tracker_format: TrackerFormat) -> None: + self._export_service.export_project( + filepath, + self._tracker_backends[tracker_format], + self._project_controller.export_request, + ) def _new(self) -> None: self._project_controller.new() @@ -257,25 +295,27 @@ def _save(self, filepath: Path) -> bool: ) return True - def _export_module(self, filepath: Path) -> None: - try: - self._project_controller.export_module(filepath) - except (ValueError, OSError) as exception: - logger.error_with_traceback( - exception, - f"Failed to export FamiTracker module to {filepath}", - ) - self._dialogs.show_error( - exception, - self._message(GlobalMessageElements.PROJECT_EXPORT_FAILED), - ) - return - - self._dialogs.show_info( - TAG_GLOBAL_DIALOG_MODULE_EXPORTED, - self._message(GlobalMessageElements.PROJECT_EXPORTED_SUCCESSFULLY), - self._title(GlobalDialogTitleElements.MODULE_EXPORTED), - ) + def _on_export_result(self, result: ExportResult) -> None: + """Reports a finished project export in the words of the format it was written in.""" + match result: + case ExportSuccess( + kind=ExportKind.PROJECT, + tracker_format=TrackerFormat() as tracker_format, + ): + self._dialogs.show_info( + TAG_GLOBAL_DIALOG_MODULE_EXPORTED, + self._message(TRACKER_PROJECT_ELEMENTS[tracker_format].exported_message), + self._title(GlobalDialogTitleElements.PROJECT_EXPORTED), + ) + case ExportError( + kind=ExportKind.PROJECT, + tracker_format=TrackerFormat() as tracker_format, + exception=exception, + ): + self._dialogs.show_error( + exception, + self._message(TRACKER_PROJECT_ELEMENTS[tracker_format].export_failed_message), + ) def _guard_open( self, diff --git a/src/sampletones_application/coordinators/reconstruction.py b/src/sampletones_application/coordinators/reconstruction.py index cc27d87b..bc1fc6b3 100644 --- a/src/sampletones_application/coordinators/reconstruction.py +++ b/src/sampletones_application/coordinators/reconstruction.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Callable, Optional +from typing import Callable, Optional, Tuple from sampletones_application.categories.elements.global_ import ( DialogElements, @@ -33,6 +33,7 @@ open_file_dialog, 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.dialogs import DialogsRenderer from sampletones_core.audio import AudioDeviceManager @@ -43,6 +44,7 @@ from sampletones_shared.exceptions import SampleToNESError from sampletones_shared.logger import logger from sampletones_shared.types.callback import Callback, VoidCallback +from sampletones_shared.utils.system.paths import get_filename class ReconstructionCoordinator: @@ -143,7 +145,7 @@ def save_as_dialog(self) -> None: default_filename = filepath.name default_path = str(filepath.parent) else: - default_filename = f"{reconstruction_data.name}{EXT_FILE_RECONSTRUCTION}" + default_filename = get_filename(reconstruction_data.name, EXT_FILE_RECONSTRUCTION) default_path = str(self._session_manager.get_reconstruction_path()) filepath = save_file_dialog( @@ -155,17 +157,25 @@ def save_as_dialog(self) -> None: ], initial_directory=default_path, default_filename=default_filename, - extensions=[EXT_FILE_RECONSTRUCTION], - filter_name=self._language_manager[ - Page.GLOBAL, - Panel.DIALOG, - TextType.FILTER, - FileFilterElements.RECONSTRUCTION, - ], + filters=self._reconstruction_filters(), ) self._handle_save_as(filepath) + def _reconstruction_filters(self) -> Tuple[FileFilter, ...]: + """The single type a reconstruction is written as and read from.""" + return ( + FileFilter.for_extensions( + self._language_manager[ + Page.GLOBAL, + Panel.DIALOG, + TextType.FILTER, + FileFilterElements.RECONSTRUCTION, + ], + [EXT_FILE_RECONSTRUCTION], + ), + ) + @ignore_none_path def _handle_save_as(self, filepath: Path) -> None: try: @@ -184,6 +194,7 @@ def _handle_save_as(self, filepath: Path) -> None: GlobalMessageElements.RECONSTRUCTION_SAVE_FAILED, ], ) + return self._session_manager.set_reconstruction_path(filepath.parent) @@ -214,13 +225,7 @@ def _load_dialog(self) -> None: ReconstructionsBrowserElements.LOAD_RECONSTRUCTION_DIALOG, ], initial_directory=self._session_manager.get_reconstruction_path(), - extensions=[EXT_FILE_RECONSTRUCTION], - filter_name=self._language_manager[ - Page.GLOBAL, - Panel.DIALOG, - TextType.FILTER, - FileFilterElements.RECONSTRUCTION, - ], + filters=self._reconstruction_filters(), ) self._handle_load(filepath) diff --git a/src/sampletones_application/coordinators/tabs/reconstruction.py b/src/sampletones_application/coordinators/tabs/reconstruction.py index a0d2f872..b47f9f7d 100644 --- a/src/sampletones_application/coordinators/tabs/reconstruction.py +++ b/src/sampletones_application/coordinators/tabs/reconstruction.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Callable, Optional +from typing import Callable, Dict, Optional, Tuple import dearpygui.dearpygui as dpg @@ -18,6 +18,10 @@ from sampletones_application.categories.export import ExportMessages from sampletones_application.categories.hierarchy import Page, Panel, TextType from sampletones_application.categories.manager import LanguageManager +from sampletones_application.categories.trackers import ( + INSTRUMENT_EXPORT_FORMATS, + TRACKER_INSTRUMENT_FILTERS, +) from sampletones_application.config.managers.config import ConfigManager from sampletones_application.config.managers.session import SessionManager from sampletones_application.coordinators.original_audio import OriginalAudioLocator @@ -41,7 +45,6 @@ from sampletones_application.services.export.result import ExportResult from sampletones_application.services.export.service import ExportService from sampletones_application.services.export.success import ExportSuccess -from sampletones_application.services.export.truncation import ExportTruncation from sampletones_application.tags.general import ( SUF_PANEL_CENTER, SUF_PANEL_LEFT, @@ -72,10 +75,8 @@ from sampletones_application.ui.panels.reconstruction.plot import ( GUIReconstructionPlotPanel, ) -from sampletones_application.utils.file_dialogs.api import ( - save_file_dialog, - select_directory_dialog, -) +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.dialogs import DialogsRenderer from sampletones_application.utils.gui.dpg import dpg_configure_item @@ -85,7 +86,12 @@ ) from sampletones_application.view_model.shared.audio_data import AudioData from sampletones_core.audio import AudioDeviceManager -from sampletones_core.paths import EXT_FILE_INSTRUMENT, EXT_FILE_WAVE +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.exporters.truncation import EnvelopeTruncation +from sampletones_core.paths import EXT_FILE_WAVE +from sampletones_core.trackers.backend import TrackerBackend +from sampletones_core.trackers.format import TrackerFormat +from sampletones_core.trackers.scope import ExportScope from sampletones_shared.exceptions import ( DeserializationError, IncompatibleReconstructionVersionError, @@ -111,6 +117,7 @@ def __init__( reconstruction_manager: ReconstructionManager, browser_manager: BrowserManager, export_service: ExportService, + tracker_backends: Dict[TrackerFormat, TrackerBackend], on_load_reconstruction_with_confirmation: Callable[[Optional[Path]], None], on_reconstruct_file: VoidCallback, on_reconstruct_directory: VoidCallback, @@ -126,6 +133,7 @@ def __init__( ) -> None: self._reconstruction_manager = reconstruction_manager self._session_manager = session_manager + self._tracker_backends = tracker_backends self._dialogs = dialogs self._original_audio_locator = original_audio_locator @@ -225,18 +233,21 @@ def __init__( TextType.TITLE, ReconstructionsInstrumentsElements.EXPORT_INSTRUMENTS_DIALOG, ] - self._filter_export_instrument = language_manager[ - Page.GLOBAL, - Panel.DIALOG, - TextType.FILTER, - FileFilterElements.INSTRUMENT, - ] self._filter_export_wav = language_manager[ Page.GLOBAL, Panel.DIALOG, TextType.FILTER, FileFilterElements.WAVE, ] + self._instrument_filter_names: Dict[TrackerFormat, str] = { + tracker_format: language_manager[ + Page.GLOBAL, + Panel.DIALOG, + TextType.FILTER, + element, + ] + for tracker_format, element in TRACKER_INSTRUMENT_FILTERS.items() + } self._msg_locate_audio_failed = language_manager[ Page.RECONSTRUCTIONS, Panel.RECONSTRUCTION, @@ -302,6 +313,7 @@ def __init__( session_manager, reconstruction_manager, export_service, + tracker_backends, ) self._reconstruction_instruments_panel: GUIReconstructionInstrumentsPanel = GUIReconstructionInstrumentsPanel( pitch_stepper_style=layout.pitch_stepper_style, @@ -392,7 +404,7 @@ def _on_export_result(self, result: ExportResult) -> None: fp, ) case ExportSuccess( - kind=ExportKind.INSTRUMENTS, + kind=ExportKind.SAMPLE, filepath=fp, truncation=truncation, ): @@ -409,14 +421,14 @@ def _on_export_result(self, result: ExportResult) -> None: self._dialogs.show_error(exception, messages.wav_failed) case ExportError(kind=ExportKind.INSTRUMENT, exception=exception): self._dialogs.show_error(exception, messages.instrument_failed) - case ExportError(kind=ExportKind.INSTRUMENTS, exception=exception): + case ExportError(kind=ExportKind.SAMPLE, exception=exception): self._dialogs.show_error(exception, messages.instruments_failed) def _export_message( self, success: str, shortened: str, - truncation: Optional[ExportTruncation], + truncation: Optional[EnvelopeTruncation], ) -> str: """Follows the success line with the frames the FamiTracker sequence limit left out. @@ -450,38 +462,84 @@ def _open_export_instrument_dialog( self, default_filename: str, default_path: str, + generator_name: GeneratorName, ) -> None: + """Prompts for the file the ``generator_name`` slice is written to. + + Every format that writes a single slice is offered at once, so the type picked in the + dialog names the tracker the slice is written for. + """ filepath = save_file_dialog( title=self._ttl_export_instrument, initial_directory=default_path, default_filename=default_filename, - extensions=[EXT_FILE_INSTRUMENT], - filter_name=self._filter_export_instrument, + filters=self._instrument_filters(), + ) + self._handle_export_instrument(filepath, generator_name) + + def _instrument_filters(self) -> Tuple[FileFilter, ...]: + """The types a destination for one slice may be given, one per tracker offered. + + Naming each tracker's own type puts the trackers an export can reach in the dialog's + type selector, so the one that is picked there names the format. + """ + return tuple( + self._tracker_filter(tracker_format, ExportScope.INSTRUMENT) for tracker_format in INSTRUMENT_EXPORT_FORMATS ) - self._handle_export_instrument(filepath) @ignore_none_path - def _handle_export_instrument(self, filepath: Path) -> None: - self._reconstruction_panel_logic.handle_export_instrument_confirmed(filepath) + def _handle_export_instrument( + self, + filepath: Path, + generator_name: GeneratorName, + ) -> None: + self._reconstruction_panel_logic.handle_export_instrument_confirmed(filepath, generator_name) - def _open_export_instruments_dialog(self, default_path: str) -> None: - directory = select_directory_dialog( + def _open_export_instruments_dialog( + self, + default_filename: str, + default_path: str, + tracker_format: TrackerFormat, + ) -> None: + """Prompts for the destination the loaded reconstruction's slices are named after. + + The tracker was chosen with the action, so the dialog offers its file type alone: a + format that gathers the whole reconstruction into one document writes it at the + destination, while one that keeps an instrument per file writes its slices beside it. + """ + destination = save_file_dialog( title=self._ttl_export_instruments, initial_directory=default_path, + default_filename=default_filename, + filters=(self._tracker_filter(tracker_format, ExportScope.SAMPLE),), + ) + self._handle_export_instruments(destination, tracker_format) + + def _tracker_filter( + self, + tracker_format: TrackerFormat, + scope: ExportScope, + ) -> FileFilter: + """The type ``tracker_format`` writes ``scope`` files as, named after that tracker.""" + return FileFilter.for_extensions( + self._instrument_filter_names[tracker_format], + [self._tracker_backends[tracker_format].extension(scope)], ) - self._handle_export_instruments(directory) @ignore_none_path - def _handle_export_instruments(self, directory: Path) -> None: - self._reconstruction_panel_logic.handle_export_instruments_confirmed(directory) + def _handle_export_instruments( + self, + destination: Path, + tracker_format: TrackerFormat, + ) -> None: + self._reconstruction_panel_logic.handle_export_instruments_confirmed(destination, tracker_format) def _open_export_wav_dialog(self, default_filename: str, default_path: str) -> None: filepath = save_file_dialog( title=self._export_messages.wav_title, initial_directory=default_path, default_filename=default_filename, - extensions=[EXT_FILE_WAVE], - filter_name=self._filter_export_wav, + filters=(FileFilter.for_extensions(self._filter_export_wav, [EXT_FILE_WAVE]),), ) self._handle_export_wav(filepath) @@ -676,8 +734,8 @@ def player(self) -> AudioPlayerProtocol: def request_export_wav_dialog(self) -> None: self._reconstruction_panel_logic.request_export_wav_dialog() - def request_export_instruments_dialog(self) -> None: - self._reconstruction_panel_logic.request_export_instruments_dialog() + def request_export_instruments_dialog(self, tracker_format: TrackerFormat) -> None: + self._reconstruction_panel_logic.request_export_instruments_dialog(tracker_format) def _on_browser_autoplay_error(self, exception: Exception) -> None: FrameCallbackManager.set_frame_callback(lambda: self._dialogs.show_error(exception)) diff --git a/src/sampletones_application/logic/project/controller.py b/src/sampletones_application/logic/project/controller.py index 5c52be87..e0656a64 100644 --- a/src/sampletones_application/logic/project/controller.py +++ b/src/sampletones_application/logic/project/controller.py @@ -3,12 +3,12 @@ from sampletones_core.constants.enums import GeneratorName from sampletones_core.constants.general import MAX_TRANSPOSE, MAX_VOLUME, MIN_TRANSPOSE -from sampletones_core.famitracker.export import write_ftm from sampletones_core.project import Project from sampletones_core.project.instruments.sample import Sample from sampletones_core.project.patterns.row import NoteCommand, Row from sampletones_core.project.song import Song from sampletones_core.reconstructions import Reconstruction +from sampletones_core.trackers.request import ProjectExport from sampletones_shared.types.callback import VoidCallback from sampletones_shared.utils.arrays import clamp from sampletones_shared.utils.callbacks import CallbackMixin @@ -94,8 +94,10 @@ def replace_project(self, project: Project, *, clean: bool) -> None: self._project_manager.install(project, clean=clean) self.call(self.on_project_replaced) - def export_module(self, path: Path) -> None: - write_ftm(path, self.project) + @property + def export_request(self) -> ProjectExport: + """Packages the current project for a tracker backend to write.""" + return ProjectExport(project=self.project) def mark_updated(self) -> None: self._touch() diff --git a/src/sampletones_application/logic/reconstruction/reconstruction.py b/src/sampletones_application/logic/reconstruction/reconstruction.py index 3c44c792..ab4a51d3 100644 --- a/src/sampletones_application/logic/reconstruction/reconstruction.py +++ b/src/sampletones_application/logic/reconstruction/reconstruction.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Callable, FrozenSet, List, Optional, Protocol, Tuple +from typing import Callable, Dict, FrozenSet, List, Optional, Protocol, Tuple import numpy as np @@ -14,12 +14,17 @@ from sampletones_application.view_model.shared.audio_data import AudioData from sampletones_application.view_model.shared.waveform_data import WaveformData from sampletones_core.constants.enums import AudioSourceType, GeneratorName -from sampletones_core.exporters import Features -from sampletones_core.paths import EXT_FILE_INSTRUMENT +from sampletones_core.exporters.feature import Features +from sampletones_core.exporters.naming import instrument_slice_name +from sampletones_core.trackers.backend import TrackerBackend +from sampletones_core.trackers.extensions import format_for_extension +from sampletones_core.trackers.format import TrackerFormat +from sampletones_core.trackers.request import InstrumentExport, SampleExport +from sampletones_core.trackers.scope import ExportScope from sampletones_shared.logger import logger from sampletones_shared.types.callback import PathCallback, VoidCallback from sampletones_shared.utils.callbacks import CallbackMixin -from sampletones_shared.utils.system.paths import open_path_in_explorer +from sampletones_shared.utils.system.paths import get_filename, open_path_in_explorer class ExportServiceProtocol(Protocol): @@ -29,11 +34,26 @@ class ExportServiceProtocol(Protocol): the service implementation; the composition root supplies the real service. """ - def export_wav(self, filepath: Path, sample_rate: int, audio: np.ndarray) -> None: ... + def export_wav( + self, + filepath: Path, + sample_rate: int, + audio: np.ndarray, + ) -> None: ... - def export_instrument(self, filepath: Path, instrument_name: str, feature: Features) -> None: ... + def export_instrument( + self, + destination: Path, + backend: TrackerBackend, + request: InstrumentExport, + ) -> None: ... - def export_instruments(self, directory: Path, exports: List[Tuple[Path, str, Features]]) -> None: ... + def export_sample( + self, + destination: Path, + backend: TrackerBackend, + request: SampleExport, + ) -> None: ... class ReconstructionPanelLogic(CallbackMixin): @@ -42,14 +62,15 @@ def __init__( session_manager: SessionManager, reconstruction_manager: ReconstructionManager, export_service: ExportServiceProtocol, + tracker_backends: Dict[TrackerFormat, TrackerBackend], ) -> None: self._session_manager = session_manager self._reconstruction_manager = reconstruction_manager self._export_service = export_service + self._tracker_backends = tracker_backends self._current_audio_source: AudioSourceType = AudioSourceType.RECONSTRUCTION self._selected_generators: List[GeneratorName] = [] - self._pending_generator_name: Optional[GeneratorName] = None self.on_view_changed: Optional[Callable[[ReconstructionViewModel], None]] = None self.on_audio_data_changed: Optional[Callable[[Optional[AudioData]], None]] = None @@ -58,8 +79,8 @@ def __init__( self.on_waveform_cleared: Optional[VoidCallback] = None self.on_waveform_source_changed: Optional[Callable[[AudioSourceType], None]] = None - self.on_open_export_instrument_dialog: Optional[Callable[[str, str], None]] = None - self.on_open_export_instruments_dialog: Optional[Callable[[str], None]] = None + self.on_open_export_instrument_dialog: Optional[Callable[[str, str, GeneratorName], None]] = None + self.on_open_export_instruments_dialog: Optional[Callable[[str, str, TrackerFormat], None]] = None self.on_open_export_wav_dialog: Optional[Callable[[str, str], None]] = None self.on_locate_audio_not_found: Optional[PathCallback] = None @@ -147,6 +168,16 @@ def request_export_instrument_dialog( self, generator_name: GeneratorName, ) -> None: + """Asks for the destination one generator slice is written to. + + Every tracker able to write a single slice is offered at once, so the generator travels + with the request to the dialog and back. The suggestion is the instrument's name on its + own, leaving the tracker to the dialog's file-type selector and to any extension typed + over it. + + Args: + generator_name: The generator whose slice is written. + """ reconstruction_data = self._reconstruction_data if not reconstruction_data: raise AssertionError("Expected reconstruction data to be loaded before exporting an instrument") @@ -155,24 +186,41 @@ def request_export_instrument_dialog( if generator_name not in feature_data.generators: return - instrument_name = f"{reconstruction_data.name} ({generator_name})" + instrument_name = self._get_instrument_name(generator_name) default_path = str(self._session_manager.get_instrument_path()) - self._pending_generator_name = generator_name self.call( self.on_open_export_instrument_dialog, instrument_name, default_path, + generator_name, ) - def request_export_instruments_dialog(self) -> None: + def request_export_instruments_dialog( + self, + tracker_format: TrackerFormat, + ) -> None: + """Asks for the destination the loaded reconstruction's slices are named after. + + The tracker comes from the action that was chosen, so the dialog offers that + tracker's file type alone and the suggestion already ends in its extension. + + Args: + tracker_format: The tracker the slices are written for. + """ reconstruction_data = self._reconstruction_data if not reconstruction_data: raise AssertionError("Expected reconstruction data to be loaded before exporting instruments") default_path = str(self._session_manager.get_instrument_path()) + extension = self._tracker_backends[tracker_format].extension(ExportScope.SAMPLE) - self.call(self.on_open_export_instruments_dialog, default_path) + self.call( + self.on_open_export_instruments_dialog, + get_filename(reconstruction_data.name, extension), + default_path, + tracker_format, + ) def request_export_wav_dialog(self) -> None: reconstruction_data = self._reconstruction_data @@ -184,40 +232,128 @@ def request_export_wav_dialog(self) -> None: self.call(self.on_open_export_wav_dialog, default_filename, default_path) - def handle_export_instrument_confirmed(self, filepath: Path) -> None: - if not self._reconstruction_data or not self._pending_generator_name: + def handle_export_instrument_confirmed( + self, + filepath: Path, + generator_name: GeneratorName, + ) -> None: + """Writes the ``generator_name`` slice of the loaded reconstruction to ``filepath``. + + The extension picks the tracker the slice is written for, and the instrument carries + the name the destination was saved under, so renaming the file in the dialog renames + the instrument the tracker lists. + + Args: + filepath: The destination the dialog was confirmed with. + generator_name: The generator whose slice is written. + """ + reconstruction_data = self._reconstruction_data + if not reconstruction_data: logger.warning("No reconstruction data available for instrument export") - self._pending_generator_name = None return - generator_name = self._pending_generator_name - instrument_name = self._get_instrument_name(generator_name) - feature = self._reconstruction_data.feature_data[generator_name] - self._pending_generator_name = None + tracker_format = self._tracker_format(filepath, ExportScope.INSTRUMENT) + feature = reconstruction_data.feature_data[generator_name] self._session_manager.set_instrument_path(filepath.parent) self._export_service.export_instrument( filepath, - instrument_name, - feature, + self._tracker_backends[tracker_format], + self._instrument_export(generator_name, feature, filepath.stem), ) - def handle_export_instruments_confirmed(self, directory: Path) -> None: + def handle_export_instruments_confirmed( + self, + destination: Path, + tracker_format: TrackerFormat, + ) -> None: + """Writes every generator slice of the loaded reconstruction to ``destination``. + + The destination names the batch: each slice takes its generator suffix from the stem, + so a format gathering the whole reconstruction into one document writes it there while + one keeping an instrument per file writes its slices beside it. + + Args: + destination: The file the export was confirmed with. + tracker_format: The tracker the slices are written for. + """ reconstruction_data = self._reconstruction_data if not reconstruction_data: logger.warning("No reconstruction data available for instruments export") return - exports = [ - ( - directory / f"{self._get_instrument_name(gen_name)}{EXT_FILE_INSTRUMENT}", - self._get_instrument_name(gen_name), - feature, - ) - for gen_name, feature in reconstruction_data.feature_data.generators.items() - ] - self._session_manager.set_instrument_path(directory.parent) - self._export_service.export_instruments(directory, exports) + base_name = destination.stem + request = SampleExport( + name=base_name, + instruments=tuple( + self._instrument_export( + generator_name, + feature, + instrument_slice_name(base_name, generator_name), + ) + for generator_name, feature in reconstruction_data.feature_data.generators.items() + ), + nes_frequency=self._nes_frequency(), + ) + self._session_manager.set_instrument_path(destination.parent) + self._export_service.export_sample( + destination, + self._tracker_backends[tracker_format], + request, + ) + + def _tracker_format( + self, + destination: Path, + scope: ExportScope, + ) -> TrackerFormat: + """Reads the tracker format out of the destination's extension. + + A save dialog answers with one of the extensions it offered, and an export offers the + types its own formats write, so every destination reaching here names a format. + + Args: + destination: The destination the export was confirmed with. + scope: The scope about to be written. + + Returns: + TrackerFormat: The format to write in. + + Raises: + ValueError: If no format able to express ``scope`` claims the extension. + """ + tracker_format = format_for_extension(self._tracker_backends, scope, destination.suffix) + if tracker_format is None: + raise ValueError(f"No tracker format writes '{destination.suffix}' for a {scope} export") + + return tracker_format + + def _instrument_export( + self, + generator_name: GeneratorName, + feature: Features, + name: str, + ) -> InstrumentExport: + """Packages one generator slice under ``name`` for a tracker backend. + + A reconstruction has no loop flag of its own — that belongs to a sample placed in + a project — so the instrument plays its envelopes once. + """ + return InstrumentExport( + name=name, + generator=generator_name, + features=feature, + loop=False, + nes_frequency=self._nes_frequency(), + ) + + def _nes_frequency(self) -> int: + """The rate the loaded reconstruction's envelopes advance at, in Hz.""" + reconstruction_data = self._reconstruction_data + if not reconstruction_data: + raise AssertionError("Expected reconstruction data to be present") + + return reconstruction_data.config.library.nes_frequency def handle_export_wav_confirmed(self, filepath: Path) -> None: reconstruction_data = self._reconstruction_data @@ -249,19 +385,13 @@ def open_reconstruction_in_explorer(self) -> None: open_path_in_explorer(filepath) - def _get_instrument_name( - self, - generator_name: Optional[GeneratorName] = None, - ) -> str: + def _get_instrument_name(self, generator_name: GeneratorName) -> str: + """Names the loaded reconstruction's slice for one generator.""" reconstruction_data = self._reconstruction_data if not reconstruction_data: raise AssertionError("Expected reconstruction data to be present") - filename = reconstruction_data.name - if generator_name is None: - return filename - - return f"{filename}_{generator_name}" + return instrument_slice_name(reconstruction_data.name, generator_name) def _emit_audio_data(self) -> None: audio_data = self._compute_audio_data() diff --git a/src/sampletones_application/services/__init__.py b/src/sampletones_application/services/__init__.py index bde18088..2ce762af 100644 --- a/src/sampletones_application/services/__init__.py +++ b/src/sampletones_application/services/__init__.py @@ -5,7 +5,6 @@ from sampletones_application.services.export.result import ExportResult from sampletones_application.services.export.service import ExportService from sampletones_application.services.export.success import ExportSuccess -from sampletones_application.services.export.truncation import ExportTruncation from sampletones_application.services.regeneration import ( RegeneratedInstrument, RegenerationResult, @@ -30,7 +29,6 @@ "ExportResult", "ExportService", "ExportSuccess", - "ExportTruncation", "RegeneratedInstrument", "RegenerationResult", "RegenerationService", diff --git a/src/sampletones_application/services/export/error.py b/src/sampletones_application/services/export/error.py index 0a0e9acf..a7405674 100644 --- a/src/sampletones_application/services/export/error.py +++ b/src/sampletones_application/services/export/error.py @@ -1,6 +1,8 @@ from dataclasses import dataclass +from typing import Optional from sampletones_application.services.export.kind import ExportKind +from sampletones_core.trackers.format import TrackerFormat @dataclass(frozen=True, eq=False) @@ -9,8 +11,10 @@ class ExportError: Attributes: kind: The artefact the run set out to produce. + tracker_format: The format the run set out to write, and ``None`` for an audio export. exception: The failure raised while writing. """ kind: ExportKind + tracker_format: Optional[TrackerFormat] exception: Exception diff --git a/src/sampletones_application/services/export/kind.py b/src/sampletones_application/services/export/kind.py index 619eedc2..5d65376c 100644 --- a/src/sampletones_application/services/export/kind.py +++ b/src/sampletones_application/services/export/kind.py @@ -6,4 +6,5 @@ class ExportKind(str, Enum): WAV = "wav" INSTRUMENT = "instrument" - INSTRUMENTS = "instruments" + SAMPLE = "sample" + PROJECT = "project" diff --git a/src/sampletones_application/services/export/service.py b/src/sampletones_application/services/export/service.py index e35ca182..e24a42d9 100644 --- a/src/sampletones_application/services/export/service.py +++ b/src/sampletones_application/services/export/service.py @@ -1,5 +1,6 @@ +from functools import partial from pathlib import Path -from typing import List, Optional, Tuple +from typing import Callable import numpy as np @@ -8,15 +9,30 @@ from sampletones_application.services.export.kind import ExportKind from sampletones_application.services.export.result import ExportResult from sampletones_application.services.export.success import ExportSuccess -from sampletones_application.services.export.truncation import ExportTruncation from sampletones_application.utils.parallelization.thread import SingleThreadExecutor from sampletones_core.audio import write_wave -from sampletones_core.exporters import Features -from sampletones_core.famitracker.sequences.truncation import SequenceTruncation +from sampletones_core.trackers.artifact import ExportArtifact +from sampletones_core.trackers.backend import TrackerBackend +from sampletones_core.trackers.format import TrackerFormat +from sampletones_core.trackers.request import ( + InstrumentExport, + ProjectExport, + SampleExport, +) from sampletones_shared.logger import logger +NO_TRACKER_FORMAT: None = None + class ExportService(ServiceBase[ExportResult]): + """Writes exports on a background thread and reports each outcome as a result. + + The tracker backend arrives per call, so the service stays free of any one file + format: it owns the thread boundary and the error boundary, and the backend owns + what lands on disk. Each result names the format it was written in, letting one + subscriber report an outcome in the words of the tracker that reads it. + """ + def __init__(self, priority: int = 0) -> None: super().__init__(priority) self._executor = SingleThreadExecutor() @@ -35,6 +51,7 @@ def task() -> None: ExportSuccess( kind=ExportKind.WAV, filepath=filepath, + tracker_format=NO_TRACKER_FORMAT, truncation=None, ) ) @@ -43,6 +60,7 @@ def task() -> None: self._emit( ExportError( kind=ExportKind.WAV, + tracker_format=NO_TRACKER_FORMAT, exception=exception, ) ) @@ -51,56 +69,83 @@ def task() -> None: def export_instrument( self, - filepath: Path, - instrument_name: str, - feature: Features, + destination: Path, + backend: TrackerBackend, + request: InstrumentExport, ) -> None: - def task() -> None: - try: - truncation = feature.save(filepath, instrument_name) - logger.info(f"Exported FamiTracker instrument: {logger.format_path(filepath)}") - self._emit( - ExportSuccess( - kind=ExportKind.INSTRUMENT, - filepath=filepath, - truncation=ExportTruncation.summarize([truncation]), - ) - ) - except Exception as exception: # pylint: disable=broad-exception-caught - logger.error_with_traceback(exception, f"Failed to export instrument: {filepath}") - self._emit( - ExportError( - kind=ExportKind.INSTRUMENT, - exception=exception, - ) - ) + self._submit( + ExportKind.INSTRUMENT, + destination, + backend.tracker_format, + partial(backend.write_instrument, destination, request), + ) - self._executor.execute(task, wait=False) + def export_sample( + self, + destination: Path, + backend: TrackerBackend, + request: SampleExport, + ) -> None: + self._submit( + ExportKind.SAMPLE, + destination, + backend.tracker_format, + partial(backend.write_sample, destination, request), + ) - def export_instruments( + def export_project( self, - directory: Path, - exports: List[Tuple[Path, str, Features]], + destination: Path, + backend: TrackerBackend, + request: ProjectExport, ) -> None: + self._submit( + ExportKind.PROJECT, + destination, + backend.tracker_format, + partial(backend.write_project, destination, request), + ) + + def _submit( + self, + kind: ExportKind, + destination: Path, + tracker_format: TrackerFormat, + write: Callable[[], ExportArtifact], + ) -> None: + """Runs one backend write on the executor and reports what it produced. + + The result reports a path the run actually wrote, so the dialog announcing it opens + a file that is there: a batch naming its slices after the destination writes those + slices rather than the destination itself. + + Args: + kind: The artefact the run produces, naming the dialog that reports it. + destination: The destination the run was given. + tracker_format: The format the run writes, carried through to the result. + write: Calls the backend and returns what it left on disk. + """ + def task() -> None: try: - directory.mkdir(parents=True, exist_ok=True) - truncations: List[Optional[SequenceTruncation]] = [] - for filepath, instrument_name, feature in exports: - truncations.append(feature.save(filepath, instrument_name)) - logger.info(f"Exported FamiTracker instrument: {logger.format_path(filepath)}") + artifact = write() + for path in artifact.paths: + logger.info(f"Exported {kind.value}: {logger.format_path(path)}") + self._emit( ExportSuccess( - kind=ExportKind.INSTRUMENTS, - filepath=directory, - truncation=ExportTruncation.summarize(truncations), + kind=kind, + filepath=artifact.paths[0] if artifact.paths else destination, + tracker_format=tracker_format, + truncation=artifact.truncation, ) ) except Exception as exception: # pylint: disable=broad-exception-caught - logger.error_with_traceback(exception, f"Failed to export instruments to: {directory}") + logger.error_with_traceback(exception, f"Failed to export to: {destination}") self._emit( ExportError( - kind=ExportKind.INSTRUMENTS, + kind=kind, + tracker_format=tracker_format, exception=exception, ) ) diff --git a/src/sampletones_application/services/export/success.py b/src/sampletones_application/services/export/success.py index 37439e4c..502a2a94 100644 --- a/src/sampletones_application/services/export/success.py +++ b/src/sampletones_application/services/export/success.py @@ -3,7 +3,8 @@ from typing import Optional from sampletones_application.services.export.kind import ExportKind -from sampletones_application.services.export.truncation import ExportTruncation +from sampletones_core.exporters.truncation import EnvelopeTruncation +from sampletones_core.trackers.format import TrackerFormat @dataclass(frozen=True) @@ -12,11 +13,13 @@ class ExportSuccess: Attributes: kind: The artefact the run produced. - filepath: The file written, or the directory a batch of instruments filled. - truncation: What the FamiTracker sequence limit left out, and ``None`` when + filepath: A file the run wrote, which a batch reports as the first of its slices. + tracker_format: The format the run wrote, and ``None`` for an audio export. + truncation: What the target format's item limit left out, and ``None`` when the export carries every frame. """ kind: ExportKind filepath: Path - truncation: Optional[ExportTruncation] + tracker_format: Optional[TrackerFormat] + truncation: Optional[EnvelopeTruncation] diff --git a/src/sampletones_application/services/export/truncation.py b/src/sampletones_application/services/export/truncation.py deleted file mode 100644 index 89db73cb..00000000 --- a/src/sampletones_application/services/export/truncation.py +++ /dev/null @@ -1,45 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from typing import Optional, Sequence - -from sampletones_core.famitracker.sequences.truncation import SequenceTruncation - - -@dataclass(frozen=True) -class ExportTruncation: - """What the FamiTracker sequence limit left out of the instruments one export wrote. - - Attributes: - frames: The frame count a shortened instrument carries. - source_frames: The longest envelope the export was given. - instruments: How many written instruments were shortened. - """ - - frames: int - source_frames: int - instruments: int - - @classmethod - def summarize( - cls, - truncations: Sequence[Optional[SequenceTruncation]], - ) -> Optional[ExportTruncation]: - """Gathers the per-instrument shortenings of one export into a single report. - - Args: - truncations: One entry per written instrument, ``None`` where it fit whole. - - Returns: - Optional[ExportTruncation]: The summary, and ``None`` when every instrument - carries its whole envelope. - """ - shortened = [truncation for truncation in truncations if truncation is not None] - if not shortened: - return None - - return cls( - frames=min(truncation.frames for truncation in shortened), - source_frames=max(truncation.source_frames for truncation in shortened), - instruments=len(shortened), - ) diff --git a/src/sampletones_application/services/regeneration.py b/src/sampletones_application/services/regeneration.py index 6d2e3311..7281497d 100644 --- a/src/sampletones_application/services/regeneration.py +++ b/src/sampletones_application/services/regeneration.py @@ -109,6 +109,7 @@ def _run( generator_name, instructions, audio, + features.initial_pitch, ) self._emit( ServiceSuccess( diff --git a/src/sampletones_application/shell.py b/src/sampletones_application/shell.py index 18b5f106..f7f54a8b 100644 --- a/src/sampletones_application/shell.py +++ b/src/sampletones_application/shell.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from functools import partial from pathlib import Path -from typing import Any, Callable, Dict, Optional +from typing import Any, Callable, Dict, Final, Optional import dearpygui.dearpygui as dpg @@ -48,6 +48,8 @@ ) from sampletones_application.utils.gui.shortcuts.ids import ( CHANNEL_SHORTCUT_IDS, + PROJECT_EXPORT_SHORTCUT_IDS, + SAMPLE_EXPORT_SHORTCUT_IDS, ShortcutId, ) from sampletones_application.utils.gui.shortcuts.keys import ( @@ -60,6 +62,7 @@ from sampletones_application.view_model.shared.menu import MenuBarViewModel from sampletones_application.viewport import ViewportManager from sampletones_core.constants.enums import GeneratorName +from sampletones_core.trackers.format import TrackerFormat from sampletones_shared.types.application import Sender from sampletones_shared.types.callback import Callback, PathCallback @@ -70,6 +73,14 @@ Tab.INSTRUCTIONS: TAG_GLOBAL_TAB_INSTRUCTIONS, } _TAG_TABS: Dict[str, Tab] = {tag: Tab(tab) for tab, tag in _TAB_TAGS.items()} +_PROJECT_EXPORT_SHORTCUTS: Final[Dict[TrackerFormat, Shortcut]] = { + TrackerFormat.FAMITRACKER: Shortcut(dpg.mvKey_M, CTRL), + TrackerFormat.BITPHASE: Shortcut(dpg.mvKey_B, CTRL), +} +_SAMPLE_EXPORT_SHORTCUTS: Final[Dict[TrackerFormat, Shortcut]] = { + TrackerFormat.FAMITRACKER: Shortcut(dpg.mvKey_I, CTRL), + TrackerFormat.BITPHASE_PRESET: Shortcut(), +} @dataclass(frozen=True) @@ -79,7 +90,7 @@ class ShortcutBindings: save_project: Callback save_project_as: Callback project_properties: Callback - export_project_module: Callback + export_project: Callable[[TrackerFormat], None] close_project: Callback exit: Callback undo: Callback @@ -93,7 +104,7 @@ class ShortcutBindings: save_reconstruction_as: Callback close_reconstruction: Callback export_wav: Callback - export_instruments: Callback + export_instruments: Callable[[TrackerFormat], None] add_reconstruction_to_sequencer: Callback open_reconstruction_in_explorer: Callback locate_original_audio: Callback @@ -224,11 +235,7 @@ def _register_shortcuts(self, bindings: ShortcutBindings) -> None: Shortcut(dpg.mvKey_S, CTRL_SHIFT), bindings.save_project_as, ) - self._shortcut_manager.register( - ShortcutId.EXPORT_PROJECT_MODULE, - Shortcut(dpg.mvKey_M, CTRL), - bindings.export_project_module, - ) + self._register_export_shortcuts(bindings) self._shortcut_manager.register( ShortcutId.PROJECT_PROPERTIES, Shortcut(dpg.mvKey_P, ALT), @@ -294,11 +301,6 @@ def _register_shortcuts(self, bindings: ShortcutBindings) -> None: Shortcut(dpg.mvKey_E, CTRL), bindings.export_wav, ) - self._shortcut_manager.register( - ShortcutId.EXPORT_RECONSTRUCTION_INSTRUMENTS, - Shortcut(dpg.mvKey_I, CTRL), - bindings.export_instruments, - ) self._shortcut_manager.register( ShortcutId.ADD_RECONSTRUCTION_TO_SEQUENCER, Shortcut(), @@ -392,6 +394,27 @@ def _register_shortcuts(self, bindings: ShortcutBindings) -> None: self._shortcut_manager.bind_all() + def _register_export_shortcuts(self, bindings: ShortcutBindings) -> None: + """Registers one export action per tracker format, the entries the Export submenus list. + + Each action carries the format it writes, so a menu entry and its key combination reach + the same coordinator call. A format registered without a key is offered by the menu + alone, which leaves the assignment to the keybindings options. + """ + for tracker_format, shortcut in _PROJECT_EXPORT_SHORTCUTS.items(): + self._shortcut_manager.register( + PROJECT_EXPORT_SHORTCUT_IDS[tracker_format], + shortcut, + partial(bindings.export_project, tracker_format), + ) + + for tracker_format, shortcut in _SAMPLE_EXPORT_SHORTCUTS.items(): + self._shortcut_manager.register( + SAMPLE_EXPORT_SHORTCUT_IDS[tracker_format], + shortcut, + partial(bindings.export_instruments, tracker_format), + ) + def _register_channel_shortcuts(self, bindings: ShortcutBindings) -> None: """Registers one action per tracker channel, plus the one that brings the whole mix back. diff --git a/src/sampletones_application/tags/general.py b/src/sampletones_application/tags/general.py index 165df7e2..f075d6f5 100644 --- a/src/sampletones_application/tags/general.py +++ b/src/sampletones_application/tags/general.py @@ -410,11 +410,11 @@ Widget.MENU, "item_file_project_properties", ) -TAG_GLOBAL_MENU_ITEM_FILE_EXPORT_MODULE = TagName( +TAG_GLOBAL_MENU_ITEM_FILE_EXPORT = TagName( Page.GLOBAL, Panel.IMPLICIT, Widget.MENU, - "item_file_export_module", + "item_file_export", ) TAG_GLOBAL_MENU_ITEM_FILE_CLOSE_PROJECT = TagName( Page.GLOBAL, diff --git a/src/sampletones_application/ui/menu.py b/src/sampletones_application/ui/menu.py index 8059918a..5ede9c32 100644 --- a/src/sampletones_application/ui/menu.py +++ b/src/sampletones_application/ui/menu.py @@ -10,6 +10,10 @@ ) from sampletones_application.categories.hierarchy import Page, Panel, TextType from sampletones_application.categories.manager import LanguageManager +from sampletones_application.categories.trackers import ( + TRACKER_PROJECT_MENU_LABELS, + TRACKER_SAMPLE_MENU_LABELS, +) from sampletones_application.layout.glyphs import PlayerGlyphs from sampletones_application.layout.player import PlayerLayout from sampletones_application.tags.general import ( @@ -17,7 +21,7 @@ TAG_GLOBAL_MENU_ITEM_EDIT_REDO, TAG_GLOBAL_MENU_ITEM_EDIT_UNDO, TAG_GLOBAL_MENU_ITEM_FILE_CLOSE_PROJECT, - TAG_GLOBAL_MENU_ITEM_FILE_EXPORT_MODULE, + TAG_GLOBAL_MENU_ITEM_FILE_EXPORT, TAG_GLOBAL_MENU_ITEM_FILE_NEW_PROJECT, TAG_GLOBAL_MENU_ITEM_FILE_OPEN_PROJECT, TAG_GLOBAL_MENU_ITEM_FILE_PROJECT_PROPERTIES, @@ -67,6 +71,8 @@ ) from sampletones_application.utils.gui.shortcuts.ids import ( CHANNEL_SHORTCUT_IDS, + PROJECT_EXPORT_SHORTCUT_IDS, + SAMPLE_EXPORT_SHORTCUT_IDS, ShortcutId, ) from sampletones_application.utils.gui.shortcuts.manager import ShortcutManager @@ -78,7 +84,7 @@ TAG_GLOBAL_MENU_ITEM_FILE_SAVE_PROJECT, TAG_GLOBAL_MENU_ITEM_FILE_SAVE_PROJECT_AS, TAG_GLOBAL_MENU_ITEM_FILE_PROJECT_PROPERTIES, - TAG_GLOBAL_MENU_ITEM_FILE_EXPORT_MODULE, + TAG_GLOBAL_MENU_ITEM_FILE_EXPORT, TAG_GLOBAL_MENU_ITEM_FILE_CLOSE_PROJECT, ) RECONSTRUCTION_ITEM_TAGS: Final[Tuple[str, ...]] = ( @@ -216,12 +222,7 @@ def _create_file_menu(self, state: MenuBarViewModel) -> None: label=self._label(MenuElements.ITEM_FILE_PROJECT_PROPERTIES), enabled=state.project_open, ) - self._shortcut_manager.add_menu_item( - ShortcutId.EXPORT_PROJECT_MODULE, - tag=TAG_GLOBAL_MENU_ITEM_FILE_EXPORT_MODULE, - label=self._label(MenuElements.ITEM_FILE_EXPORT_MODULE), - enabled=state.project_open, - ) + self._create_project_export_menu(state) dpg.add_separator() self._shortcut_manager.add_menu_item( ShortcutId.CLOSE_PROJECT, @@ -235,6 +236,24 @@ def _create_file_menu(self, state: MenuBarViewModel) -> None: label=self._label(MenuElements.ITEM_FILE_EXIT), ) + def _create_project_export_menu(self, state: MenuBarViewModel) -> None: + """Builds the submenu that writes the open project for one tracker. + + Each format reads its own kind of file, so the formats are listed side by side and + the one chosen decides what the destination dialog offers. The submenu is open while + a project is, which is where the whole group takes its enabled state. + """ + with dpg.menu( + tag=TAG_GLOBAL_MENU_ITEM_FILE_EXPORT, + label=self._label(MenuElements.GROUP_FILE_EXPORT), + enabled=state.project_open, + ): + for tracker_format, shortcut_id in PROJECT_EXPORT_SHORTCUT_IDS.items(): + self._shortcut_manager.add_menu_item( + shortcut_id, + label=self._label(TRACKER_PROJECT_MENU_LABELS[tracker_format]), + ) + def _create_edit_menu(self, state: MenuBarViewModel) -> None: with dpg.menu(label=self._label(MenuElements.GROUP_EDIT)): self._shortcut_manager.add_menu_item( @@ -322,12 +341,24 @@ def _create_reconstruction_menu(self, state: MenuBarViewModel) -> None: label=self._label(MenuElements.ITEM_RECONSTRUCTION_EXPORT_WAV), enabled=state.reconstruction_loaded, ) - self._shortcut_manager.add_menu_item( - ShortcutId.EXPORT_RECONSTRUCTION_INSTRUMENTS, - tag=TAG_GLOBAL_MENU_ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS, - label=self._label(MenuElements.ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS), - enabled=state.reconstruction_loaded, - ) + self._create_instruments_export_menu(state) + + def _create_instruments_export_menu(self, state: MenuBarViewModel) -> None: + """Builds the submenu that writes the loaded reconstruction's slices for one tracker. + + Each format able to write a file per slice gets its own item, so choosing the tracker + is one click and the destination dialog then offers that tracker's file type alone. + """ + with dpg.menu( + tag=TAG_GLOBAL_MENU_ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS, + label=self._label(MenuElements.GROUP_RECONSTRUCTION_EXPORT_INSTRUMENTS), + enabled=state.reconstruction_loaded, + ): + for tracker_format, shortcut_id in SAMPLE_EXPORT_SHORTCUT_IDS.items(): + self._shortcut_manager.add_menu_item( + shortcut_id, + label=self._label(TRACKER_SAMPLE_MENU_LABELS[tracker_format]), + ) def _create_playback_menu(self, state: MenuBarViewModel) -> None: with dpg.menu(label=self._label(MenuElements.GROUP_PLAYBACK)): diff --git a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py index 4aaa4ad4..7702e258 100644 --- a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py +++ b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py @@ -70,8 +70,8 @@ ) from sampletones_core.constants.general import MAX_PERIOD, MIN_PITCH from sampletones_core.exporters import Features -from sampletones_core.famitracker.specification.sequences import MAX_SEQUENCE_ITEMS from sampletones_core.features import GENERATOR_KIND, supported_features +from sampletones_core.formats.famitracker.specification.sequences import MAX_SEQUENCE_ITEMS from sampletones_core.utils.pitch_kind import ( PERIOD_VALUE_KIND, PITCH_VALUE_KIND, @@ -79,6 +79,7 @@ ) from sampletones_shared.logger import logger from sampletones_shared.types.application import Sender +from sampletones_shared.types.callback import VoidCallback from sampletones_shared.utils.arrays import clamp OnInstrumentExportCallback = Callable[[GeneratorName], None] @@ -328,19 +329,20 @@ def _setup_mouse_event_handler(self) -> None: with dpg.handler_registry(tag=self.mouse_item_handler_tag): dpg.add_mouse_move_handler(callback=self._on_mouse_move) - def _handle_export_button_clicked( - self, - sender: Sender, - app_data: Any, - user_data: GeneratorName, - ) -> None: - self.call(self.on_instrument_export, user_data) + def _export_callback(self, generator_name: GeneratorName) -> VoidCallback: + """The press handler for one generator's export button. + the generator is captured in a closure, which carries one. + """ + return lambda: self.call(self.on_instrument_export, generator_name) def _create_tabs_for_generators(self) -> None: for generator_name in GeneratorName.items(): self._create_generator_tab(generator_name) - def _generator_kind(self, generator_name: GeneratorName) -> LibraryGeneratorName: + def _generator_kind( + self, + generator_name: GeneratorName, + ) -> LibraryGeneratorName: return GENERATOR_KIND[generator_name] def _generator_features( @@ -369,8 +371,7 @@ def _create_generator_tab(self, generator_name: GeneratorName) -> None: parent=tab_tag, label=self._lbl_export_instrument, width=-1, - callback=self._handle_export_button_clicked, - user_data=generator_name, + callback=self._export_callback(generator_name), ) self._status_bar.bind_to_item( button_tag, diff --git a/src/sampletones_application/utils/file_dialogs/api.py b/src/sampletones_application/utils/file_dialogs/api.py index 5fde1764..2ae187c0 100644 --- a/src/sampletones_application/utils/file_dialogs/api.py +++ b/src/sampletones_application/utils/file_dialogs/api.py @@ -1,10 +1,9 @@ +from itertools import chain from pathlib import Path -from typing import Iterable, Optional, Tuple +from typing import Optional, Tuple -from sampletones_application.utils.file_dialogs.filter import ( - FileFilter, - normalize_extensions, -) +from sampletones_application.utils.file_dialogs.destination import SaveDestination +from sampletones_application.utils.file_dialogs.filter import FileFilter from sampletones_application.utils.file_dialogs.selection import ( select_file_dialog_backend, ) @@ -16,14 +15,13 @@ def open_file_dialog( *, title: str, initial_directory: Optional[Pathlike] = None, - extensions: Iterable[str] = (), - filter_name: Optional[str] = None, + filters: Tuple[FileFilter, ...] = (), ) -> Optional[Path]: backend = select_file_dialog_backend() return backend.open_file( title=title, initial_directory=_optional_path(initial_directory), - file_filter=_build_filter(normalize_extensions(extensions), filter_name), + filters=filters, ) @@ -32,25 +30,27 @@ def save_file_dialog( title: str, initial_directory: Optional[Pathlike] = None, default_filename: Optional[str] = None, - extensions: Iterable[str] = (), - filter_name: Optional[str] = None, + filters: Tuple[FileFilter, ...] = (), ) -> Optional[Path]: - patterns = normalize_extensions(extensions) + """ + Asks for a destination to save to, yielding ``None`` once the dialog is dismissed. + + The answer carries one of the offered extensions, so a caller receives a destination it + can write straight away and a caller reading the format out of the extension always + finds one. ``filters`` is ordered, and its first type is the one the dialog opens on. + """ backend = select_file_dialog_backend() - path = backend.save_file( + destination = backend.save_file( title=title, initial_directory=_optional_path(initial_directory), suggested_name=default_filename, - file_filter=_build_filter(patterns, filter_name), + filters=filters, ) - if path is None: + if destination is None: return None - if len(patterns) == 1: - path = ensure_suffix(path, patterns[0].removeprefix("*")) - - return path + return _with_offered_extension(destination, filters) def select_directory_dialog( @@ -65,15 +65,51 @@ def select_directory_dialog( ) -def _optional_path(value: Optional[Pathlike]) -> Optional[Path]: - return to_path(value) if value is not None else None +def _with_offered_extension( + destination: SaveDestination, + filters: Tuple[FileFilter, ...], +) -> Path: + """ + Returns the destination's path ending in an extension one of the offered types accepts. + A name already carrying one of those extensions stands as it is, so typing an extension is + how a type is named where a dialog reports none. Any other name takes the extension of the + governing type: the one the dialog reported for a dialog whose selector carries the choice, + and otherwise the type the dialog opened on. + """ + offered = _offered_extensions(filters) + if not offered: + return destination.path -def _build_filter( - patterns: Tuple[str, ...], - filter_name: Optional[str], -) -> Optional[FileFilter]: - if not patterns: - return None + if _carries_one_of(destination.path, offered): + return destination.path + + return ensure_suffix(destination.path, _governing_extension(destination.file_type, offered)) + + +def _governing_extension( + file_type: Optional[FileFilter], + offered: Tuple[str, ...], +) -> str: + """The extension a name carrying none of the offered ones is saved under.""" + if file_type is not None and file_type.extensions: + return file_type.extensions[0] - return FileFilter(name=filter_name or "", patterns=patterns) + return offered[0] + + +def _carries_one_of( + path: Path, + extensions: Tuple[str, ...], +) -> bool: + name = path.name.lower() + return any(name.endswith(extension.lower()) for extension in extensions) + + +def _offered_extensions(filters: Tuple[FileFilter, ...]) -> Tuple[str, ...]: + """The extensions every offered type accepts, in the order the types are shown.""" + return tuple(chain.from_iterable(file_filter.extensions for file_filter in filters)) + + +def _optional_path(value: Optional[Pathlike]) -> Optional[Path]: + return to_path(value) if value is not None else None diff --git a/src/sampletones_application/utils/file_dialogs/backend.py b/src/sampletones_application/utils/file_dialogs/backend.py deleted file mode 100644 index 3bdf5c76..00000000 --- a/src/sampletones_application/utils/file_dialogs/backend.py +++ /dev/null @@ -1,38 +0,0 @@ -from pathlib import Path -from typing import Optional, Protocol - -from sampletones_application.utils.file_dialogs.filter import FileFilter - - -class FileDialogBackend(Protocol): - """ - A native file-dialog implementation for one platform or desktop tool. - - An implementation drives a system dialog (kdialog, zenity) or ``tkinter`` and - returns the chosen path, yielding ``None`` when the user cancels. The selector in - ``selection`` picks the implementation that fits the running environment. - """ - - def open_file( - self, - *, - title: str, - initial_directory: Optional[Path], - file_filter: Optional[FileFilter], - ) -> Optional[Path]: ... - - def save_file( - self, - *, - title: str, - initial_directory: Optional[Path], - suggested_name: Optional[str], - file_filter: Optional[FileFilter], - ) -> Optional[Path]: ... - - def select_directory( - self, - *, - title: str, - initial_directory: Optional[Path], - ) -> Optional[Path]: ... diff --git a/src/sampletones_core/famitracker/__init__.py b/src/sampletones_application/utils/file_dialogs/backends/__init__.py similarity index 100% rename from src/sampletones_core/famitracker/__init__.py rename to src/sampletones_application/utils/file_dialogs/backends/__init__.py diff --git a/src/sampletones_application/utils/file_dialogs/backends/command.py b/src/sampletones_application/utils/file_dialogs/backends/command.py new file mode 100644 index 00000000..96cd0da8 --- /dev/null +++ b/src/sampletones_application/utils/file_dialogs/backends/command.py @@ -0,0 +1,28 @@ +import subprocess +from pathlib import Path +from typing import List, Optional + +from sampletones_shared.utils.system.paths import normalize_path + + +def run_dialog_command(command: List[str]) -> Optional[Path]: + """ + Runs a command-line dialog tool and returns the path it reports. + + ``kdialog`` and ``zenity`` share one contract: the chosen path arrives on standard output, + and a dismissed dialog leaves that output empty, which answers ``None``. The exit status + carries the same dismissal, so the reported path alone decides the answer. + + Args: + command (List[str]): The tool and the arguments to run it with. + + Returns: + Optional[Path]: The path the dialog reports, or ``None`` once the dialog is dismissed. + """ + result = subprocess.run( + command, + capture_output=True, + text=True, + check=False, + ) + return normalize_path(result.stdout.strip()) diff --git a/src/sampletones_application/utils/file_dialogs/backends/kdialog.py b/src/sampletones_application/utils/file_dialogs/backends/kdialog.py new file mode 100644 index 00000000..8c5a5dbe --- /dev/null +++ b/src/sampletones_application/utils/file_dialogs/backends/kdialog.py @@ -0,0 +1,91 @@ +from pathlib import Path +from typing import List, Optional, Tuple + +from sampletones_application.utils.file_dialogs.backends.command import run_dialog_command +from sampletones_application.utils.file_dialogs.destination import ( + SaveDestination, + untyped_destination, +) +from sampletones_application.utils.file_dialogs.filter import FileFilter, merge_filters + + +class KDialogBackend: + """ + File dialogs backed by KDE's ``kdialog`` (Qt). + + ``kdialog`` activates the supplied filter, so the file-type selector opens on the + chosen type. Its command line carries one filter, so offering a single type hands KDE + a lone pattern and its own extension checkbox fills that extension in; several types + gather into one filter whose label names each of them. + """ + + def open_file( + self, + *, + title: str, + initial_directory: Optional[Path], + filters: Tuple[FileFilter, ...], + ) -> Optional[Path]: + command = [ + "kdialog", + "--getopenfilename", + self._start_location(initial_directory), + ] + command += self._filter_arguments(filters) + command += ["--title", title] + return run_dialog_command(command) + + def save_file( + self, + *, + title: str, + initial_directory: Optional[Path], + suggested_name: Optional[str], + filters: Tuple[FileFilter, ...], + ) -> Optional[SaveDestination]: + command = [ + "kdialog", + "--getsavefilename", + self._start_location( + initial_directory, + suggested_name, + ), + ] + command += self._filter_arguments(filters) + command += ["--title", title] + return untyped_destination(run_dialog_command(command)) + + def select_directory( + self, + *, + title: str, + initial_directory: Optional[Path], + ) -> Optional[Path]: + command = [ + "kdialog", + "--getexistingdirectory", + self._start_location(initial_directory), + "--title", + title, + ] + return run_dialog_command(command) + + @staticmethod + def _start_location( + initial_directory: Optional[Path], + suggested_name: Optional[str] = None, + ) -> str: + base = initial_directory if initial_directory is not None else Path.home() + if suggested_name: + return str(base / suggested_name) + + return str(base) + + @staticmethod + def _filter_arguments(filters: Tuple[FileFilter, ...]) -> List[str]: + merged = merge_filters(filters) + if merged is None: + return [] + + patterns = " ".join(merged.patterns) + return [f"{patterns}|{merged.label}"] diff --git a/src/sampletones_core/famitracker/model/__init__.py b/src/sampletones_application/utils/file_dialogs/backends/portal/__init__.py similarity index 100% rename from src/sampletones_core/famitracker/model/__init__.py rename to src/sampletones_application/utils/file_dialogs/backends/portal/__init__.py diff --git a/src/sampletones_application/utils/file_dialogs/backends/portal/backend.py b/src/sampletones_application/utils/file_dialogs/backends/portal/backend.py new file mode 100644 index 00000000..32269afa --- /dev/null +++ b/src/sampletones_application/utils/file_dialogs/backends/portal/backend.py @@ -0,0 +1,222 @@ +from functools import lru_cache +from pathlib import Path +from typing import Dict, Final, List, Optional, Tuple +from urllib.parse import unquote, urlparse + +from sampletones_application.utils.file_dialogs.backends.portal.client import FileChooserClient +from sampletones_application.utils.file_dialogs.backends.portal.response import ChooserResult +from sampletones_application.utils.file_dialogs.backends.portal.variant import ( + BOOLEAN_SIGNATURE, + BYTES_SIGNATURE, + STRING_SIGNATURE, + Variant, +) +from sampletones_application.utils.file_dialogs.destination import SaveDestination +from sampletones_application.utils.file_dialogs.filter import FileFilter + +OPEN_FILE_METHOD: Final[str] = "OpenFile" +SAVE_FILE_METHOD: Final[str] = "SaveFile" + +FILTERS_OPTION: Final[str] = "filters" +CURRENT_FILTER_OPTION: Final[str] = "current_filter" +CURRENT_NAME_OPTION: Final[str] = "current_name" +CURRENT_FOLDER_OPTION: Final[str] = "current_folder" +DIRECTORY_OPTION: Final[str] = "directory" + +FILTER_SIGNATURE: Final[str] = "(sa(us))" +FILTERS_SIGNATURE: Final[str] = f"a{FILTER_SIGNATURE}" + +GLOB_PATTERN: Final[int] = 0 +"""The portal's kind for a filter pattern written as a shell glob.""" + +FILE_SCHEME: Final[str] = "file" +PATH_TERMINATOR: Final[bytes] = b"\0" + +MINIMUM_FILE_CHOOSER_VERSION: Final[int] = 3 +"""The version reporting the chosen type and accepting a folder to open in.""" + +PortalFilter = Tuple[str, List[Tuple[int, str]]] + + +class PortalBackend: + """ + File dialogs opened through the XDG desktop portal. + + The portal hands each request to the desktop's own file chooser, so a dialog looks and + behaves as the rest of the desktop does. Every offered type reaches the file-type selector + as its own entry and the response names the entry the user left it on, which is what lets a + save settle its extension from the type that was picked rather than the name that was typed. + """ + + def __init__(self, client: FileChooserClient) -> None: + self._client = client + + def open_file( + self, + *, + title: str, + initial_directory: Optional[Path], + filters: Tuple[FileFilter, ...], + ) -> Optional[Path]: + result = self._client.call( + method=OPEN_FILE_METHOD, + title=title, + options=self._open_options( + initial_directory, + filters, + ), + ) + return self._chosen_path(result) + + def save_file( + self, + *, + title: str, + initial_directory: Optional[Path], + suggested_name: Optional[str], + filters: Tuple[FileFilter, ...], + ) -> Optional[SaveDestination]: + result = self._client.call( + method=SAVE_FILE_METHOD, + title=title, + options=self._save_options( + initial_directory, + suggested_name, + filters, + ), + ) + path = self._chosen_path(result) + if result is None or path is None: + return None + + return SaveDestination( + path=path, + file_type=self._reported_type( + result, + filters, + ), + ) + + def select_directory( + self, + *, + title: str, + initial_directory: Optional[Path], + ) -> Optional[Path]: + result = self._client.call( + method=OPEN_FILE_METHOD, + title=title, + options=self._directory_options(initial_directory), + ) + return self._chosen_path(result) + + @classmethod + def _open_options( + cls, + initial_directory: Optional[Path], + filters: Tuple[FileFilter, ...], + ) -> Dict[str, Variant]: + return { + **cls._folder_option(initial_directory), + **cls._filter_options(filters), + } + + @classmethod + def _save_options( + cls, + initial_directory: Optional[Path], + suggested_name: Optional[str], + filters: Tuple[FileFilter, ...], + ) -> Dict[str, Variant]: + options: Dict[str, Variant] = { + **cls._folder_option(initial_directory), + **cls._filter_options(filters), + } + if suggested_name: + options[CURRENT_NAME_OPTION] = (STRING_SIGNATURE, suggested_name) + + return options + + @classmethod + def _directory_options( + cls, + initial_directory: Optional[Path], + ) -> Dict[str, Variant]: + return { + **cls._folder_option(initial_directory), + DIRECTORY_OPTION: (BOOLEAN_SIGNATURE, True), + } + + @staticmethod + def _folder_option(initial_directory: Optional[Path]) -> Dict[str, Variant]: + """The folder the dialog opens in, as the NUL-terminated byte string the portal reads.""" + if initial_directory is None: + return {} + + encoded = str(initial_directory).encode() + PATH_TERMINATOR + return {CURRENT_FOLDER_OPTION: (BYTES_SIGNATURE, encoded)} + + @classmethod + def _filter_options( + cls, + filters: Tuple[FileFilter, ...], + ) -> Dict[str, Variant]: + """ + The types the selector lists, and the one it opens on. + + Naming the first type as the current one opens the dialog on the type a caller offers first, + matching the extension a suggested name carries. + """ + if not filters: + return {} + + listed = [cls._portal_filter(file_filter) for file_filter in filters] + return { + FILTERS_OPTION: (FILTERS_SIGNATURE, listed), + CURRENT_FILTER_OPTION: (FILTER_SIGNATURE, listed[0]), + } + + @staticmethod + def _portal_filter(file_filter: FileFilter) -> PortalFilter: + patterns = [(GLOB_PATTERN, pattern) for pattern in file_filter.patterns] + return (file_filter.label, patterns) + + @staticmethod + def _reported_type( + result: ChooserResult, + filters: Tuple[FileFilter, ...], + ) -> Optional[FileFilter]: + """The offered type whose label the dialog reported, for a portal implementation reporting one.""" + for file_filter in filters: + if file_filter.label == result.filter_label: + return file_filter + + return None + + @staticmethod + def _chosen_path(result: Optional[ChooserResult]) -> Optional[Path]: + """The local path the dialog answered with, for the ``file`` locations the portal hands back.""" + if result is None or not result.uris: + return None + + location = urlparse(result.uris[0]) + if location.scheme != FILE_SCHEME: + return None + + return Path(unquote(location.path)) + + +@lru_cache(maxsize=1) +def portal_backend() -> Optional[PortalBackend]: + """ + Returns portal-backed dialogs once a portal implementing ``FileChooser`` answers on the bus. + + The answer holds for the life of the process, since a desktop either runs a portal or leaves + dialogs to another backend, so every dialog after the first opens with no further round trip. + """ + client = FileChooserClient() + version = client.version() + if version is None or version < MINIMUM_FILE_CHOOSER_VERSION: + return None + + return PortalBackend(client) diff --git a/src/sampletones_application/utils/file_dialogs/backends/portal/client.py b/src/sampletones_application/utils/file_dialogs/backends/portal/client.py new file mode 100644 index 00000000..2a7beea7 --- /dev/null +++ b/src/sampletones_application/utils/file_dialogs/backends/portal/client.py @@ -0,0 +1,182 @@ +from typing import Deque, Dict, Final, Optional, Tuple, Type, cast + +from jeepney import ( + AuthenticationError, + DBusAddress, + DBusErrorResponse, + HeaderFields, + MatchRule, + Properties, + message_bus, + new_method_call, +) +from jeepney.io.blocking import DBusConnection, open_dbus_connection, unwrap_msg +from jeepney.low_level import Message + +from sampletones_application.utils.file_dialogs.backends.portal.parent import parent_window_handle +from sampletones_application.utils.file_dialogs.backends.portal.response import ChooserResult +from sampletones_application.utils.file_dialogs.backends.portal.variant import Variant + +SESSION_BUS: Final[str] = "SESSION" +PORTAL_BUS_NAME: Final[str] = "org.freedesktop.portal.Desktop" +PORTAL_OBJECT_PATH: Final[str] = "/org/freedesktop/portal/desktop" +FILE_CHOOSER_INTERFACE: Final[str] = "org.freedesktop.portal.FileChooser" +REQUEST_INTERFACE: Final[str] = "org.freedesktop.portal.Request" +BUS_INTERFACE: Final[str] = "org.freedesktop.DBus" +RESPONSE_SIGNAL: Final[str] = "Response" +NAME_OWNER_CHANGED_SIGNAL: Final[str] = "NameOwnerChanged" +VERSION_PROPERTY: Final[str] = "version" + +CALL_SIGNATURE: Final[str] = "ssa{sv}" +BUS_NAME_ARGUMENT: Final[int] = 0 +NO_OWNER: Final[str] = "" + +PORTAL_OUT_OF_REACH_ERRORS: Final[Tuple[Type[Exception], ...]] = ( + KeyError, + RuntimeError, + OSError, + AuthenticationError, + DBusErrorResponse, +) + +FILE_CHOOSER: Final[DBusAddress] = DBusAddress( + PORTAL_OBJECT_PATH, + bus_name=PORTAL_BUS_NAME, + interface=FILE_CHOOSER_INTERFACE, +) + + +class FileChooserClient: + """ + The desktop portal's ``FileChooser`` interface, reached over the session bus. + + A call asks the portal for a dialog and answers once the user closes it. The portal replies + to the call with the object path of a request and delivers the outcome as a signal on that + path, so each call subscribes to the signal before asking and then waits for the response + belonging to its own request. The same subscription covers the bus announcing who owns the + portal's name, which is what tells a waiting call that the portal it is waiting on left. + Every dialog runs in the desktop's own portal implementation, which is what makes the + file-type selector and the type it reports available at all. + """ + + def version(self) -> Optional[int]: + """ + Returns the ``FileChooser`` version the portal on the session bus implements. + + Answers ``None`` where the session bus is out of reach or no portal claims the + interface, which is the environment's way of saying dialogs belong to another backend. + """ + try: + with open_dbus_connection(bus=SESSION_BUS) as connection: + reply = connection.send_and_get_reply(Properties(FILE_CHOOSER).get(VERSION_PROPERTY)) + (version,) = cast(Tuple[Variant], unwrap_msg(reply)) + except PORTAL_OUT_OF_REACH_ERRORS: + return None + + return cast(int, version[1]) + + def call( + self, + *, + method: str, + title: str, + options: Dict[str, Variant], + ) -> Optional[ChooserResult]: + """ + Opens the dialog ``method`` names and waits for the user to answer it. + + The call names this application's window as the dialog's parent, which is what places + the dialog over the window it was asked from. + + Args: + method: The ``FileChooser`` method to call, naming the kind of dialog to open. + title: The window title the dialog carries. + options: The portal options for that method, each value a D-Bus variant. + + Returns: + Optional[ChooserResult]: What the dialog answered, ``None`` once it was dismissed or + once the portal drawing it left the bus. + """ + response_rule = self._response_rule() + owner_rule = self._portal_owner_rule() + request = new_method_call( + FILE_CHOOSER, + method, + CALL_SIGNATURE, + ( + parent_window_handle(), + title, + options, + ), + ) + + with open_dbus_connection(bus=SESSION_BUS) as connection: + with connection.filter(response_rule) as signals, connection.filter(owner_rule, queue=signals): + connection.send_and_get_reply(message_bus.AddMatch(response_rule)) + connection.send_and_get_reply(message_bus.AddMatch(owner_rule)) + (handle,) = cast(Tuple[str], unwrap_msg(connection.send_and_get_reply(request))) + return self._answer( + connection, + signals, + handle, + ) + + @staticmethod + def _response_rule() -> MatchRule: + """Subscribes to the outcome of every portal request, each call recognising its own.""" + return MatchRule( + type="signal", + interface=REQUEST_INTERFACE, + member=RESPONSE_SIGNAL, + ) + + @staticmethod + def _portal_owner_rule() -> MatchRule: + """Subscribes to the bus announcing the portal's name changing hands.""" + rule = MatchRule( + type="signal", + interface=BUS_INTERFACE, + member=NAME_OWNER_CHANGED_SIGNAL, + ) + rule.add_arg_condition(BUS_NAME_ARGUMENT, PORTAL_BUS_NAME) + return rule + + @classmethod + def _answer( + cls, + connection: DBusConnection, + signals: Deque[Message], + handle: str, + ) -> Optional[ChooserResult]: + """ + Waits for the request ``handle`` to answer, or for the portal owing that answer to leave. + + A dialog stands open for as long as the user takes over it, so the wait runs to the user's + own pace. What bounds it instead is the portal: the bus announces the name being released, + and a released name means the dialog on screen went with the process that drew it, leaving + a request that answers to nobody. That ends the wait the way a dismissal does, since either + way the user named no destination. + """ + while True: + signal = connection.recv_until_filtered(signals) + if cls._portal_left_the_bus(signal): + return None + + if cls._signal_path(signal) == handle: + return ChooserResult.from_response(signal) + + @classmethod + def _portal_left_the_bus(cls, signal: Message) -> bool: + if cls._signal_member(signal) != NAME_OWNER_CHANGED_SIGNAL: + return False + + _name, _previous_owner, current_owner = cast(Tuple[str, str, str], signal.body) + return current_owner == NO_OWNER + + @staticmethod + def _signal_path(signal: Message) -> Optional[str]: + return cast(Optional[str], signal.header.fields.get(HeaderFields.path)) + + @staticmethod + def _signal_member(signal: Message) -> Optional[str]: + return cast(Optional[str], signal.header.fields.get(HeaderFields.member)) diff --git a/src/sampletones_application/utils/file_dialogs/backends/portal/parent.py b/src/sampletones_application/utils/file_dialogs/backends/portal/parent.py new file mode 100644 index 00000000..55c9ee11 --- /dev/null +++ b/src/sampletones_application/utils/file_dialogs/backends/portal/parent.py @@ -0,0 +1,191 @@ +import ctypes +import os +from ctypes import CDLL, POINTER, byref, c_char_p, c_int, c_long, c_ubyte, c_ulong, c_void_p +from typing import Final, List, Optional, Self + +X11_LIBRARY: Final[str] = "libX11.so.6" + +CLIENT_LIST_PROPERTY: Final[bytes] = b"_NET_CLIENT_LIST" +PROCESS_PROPERTY: Final[bytes] = b"_NET_WM_PID" + +WINDOW_ATOM: Final[int] = 33 +CARDINAL_ATOM: Final[int] = 6 + +NO_ATOM: Final[int] = 0 +PROPERTY_READ: Final[int] = 0 + +PROPERTY_OFFSET: Final[int] = 0 +PROPERTY_WORD_LIMIT: Final[int] = 1024 + +ATOM_MUST_EXIST: Final[bool] = True +KEEP_PROPERTY: Final[bool] = False + +X11_HANDLE_PREFIX: Final[str] = "x11:" +NO_PARENT_WINDOW: Final[str] = "" + + +class X11Display: + """ + A connection to the X server, opened to read the windows an X11 desktop manages. + + The desktop lists the windows it manages on the root window and each listed window carries + the process that owns it, so an application finds its own window by the process it runs as. + A connection holds a socket to the server for as long as it stays open, which ``close`` + releases once a lookup is done with it. + """ + + def __init__( + self, + library: CDLL, + display: int, + ) -> None: + self._library = library + self._display = display + + @classmethod + def open(cls) -> Optional[Self]: + """ + Opens the display the environment names. + + Returns: + Optional[Self]: The open connection, ``None`` where libX11 is out of reach or the + environment names no server, which is how a session running without X11 answers. + """ + try: + library = CDLL(X11_LIBRARY) + except OSError: + return None + + cls._declare_signatures(library) + display = library.XOpenDisplay(None) + if not display: + return None + + return cls(library, display) + + def close(self) -> None: + """Releases the connection to the server.""" + self._library.XCloseDisplay(self._display) + + def window_of_process(self, process_id: int) -> Optional[int]: + """ + Returns the identifier of the window the desktop manages for a process. + + The window list and the process owning a window are properties the X server gives types + of its own, which a read names by the atoms those types are known under. + + Args: + process_id: The process whose window to look for. + + Returns: + Optional[int]: The first listed window that process owns, ``None`` where the desktop + lists none for it. + """ + root = self._library.XDefaultRootWindow(self._display) + for window in self._numbers(root, CLIENT_LIST_PROPERTY, WINDOW_ATOM): + if process_id in self._numbers(window, PROCESS_PROPERTY, CARDINAL_ATOM): + return window + + return None + + def _numbers( + self, + window: int, + name: bytes, + value_type: int, + ) -> List[int]: + """ + The numbers a window's property holds, empty for a window carrying no such property. + + The server reports what it read through the values passed by reference, and owns the + array it answers with until ``XFree`` releases it. A property of the 32-bit format + arrives as an array of C longs, which is what its values are read as, and one read takes + as many of those words as a desktop's window list needs. + """ + atom = self._library.XInternAtom(self._display, name, ATOM_MUST_EXIST) + if atom == NO_ATOM: + return [] + + type_read = c_ulong(0) + format_read = c_int(0) + items_read = c_ulong(0) + remaining = c_ulong(0) + values = POINTER(c_ubyte)() + status = self._library.XGetWindowProperty( + self._display, + window, + atom, + PROPERTY_OFFSET, + PROPERTY_WORD_LIMIT, + KEEP_PROPERTY, + value_type, + byref(type_read), + byref(format_read), + byref(items_read), + byref(remaining), + byref(values), + ) + if status != PROPERTY_READ or not values: + return [] + + try: + numbers = ctypes.cast(values, POINTER(c_ulong)) + return [int(numbers[index]) for index in range(items_read.value)] + finally: + self._library.XFree(values) + + @staticmethod + def _declare_signatures(library: CDLL) -> None: + """The types of the libX11 calls a lookup makes, which ctypes reads to marshal them.""" + library.XOpenDisplay.argtypes = [c_char_p] + library.XOpenDisplay.restype = c_void_p + library.XCloseDisplay.argtypes = [c_void_p] + library.XCloseDisplay.restype = c_int + library.XDefaultRootWindow.argtypes = [c_void_p] + library.XDefaultRootWindow.restype = c_ulong + library.XInternAtom.argtypes = [c_void_p, c_char_p, c_int] + library.XInternAtom.restype = c_ulong + library.XGetWindowProperty.argtypes = [ + c_void_p, + c_ulong, + c_ulong, + c_long, + c_long, + c_int, + c_ulong, + POINTER(c_ulong), + POINTER(c_int), + POINTER(c_ulong), + POINTER(c_ulong), + POINTER(POINTER(c_ubyte)), + ] + library.XGetWindowProperty.restype = c_int + library.XFree.argtypes = [c_void_p] + library.XFree.restype = c_int + + +def parent_window_handle() -> str: + """ + Returns the handle naming the window a portal dialog belongs to. + + The portal gives a dialog the window that asked for it as its parent, which is what keeps + the dialog above that window and lets the desktop place it there. An X11 desktop names a + window by its identifier written in hexadecimal. + + Returns: + str: The handle naming this application's window, empty where the session names no such + window, which asks the portal for a dialog standing on its own. + """ + display = X11Display.open() + if display is None: + return NO_PARENT_WINDOW + + try: + window_id = display.window_of_process(os.getpid()) + finally: + display.close() + + if window_id is None: + return NO_PARENT_WINDOW + + return f"{X11_HANDLE_PREFIX}{window_id:x}" diff --git a/src/sampletones_application/utils/file_dialogs/backends/portal/response.py b/src/sampletones_application/utils/file_dialogs/backends/portal/response.py new file mode 100644 index 00000000..f8aa5bb6 --- /dev/null +++ b/src/sampletones_application/utils/file_dialogs/backends/portal/response.py @@ -0,0 +1,63 @@ +from dataclasses import dataclass +from typing import Dict, Final, List, Optional, Self, Tuple, cast + +from jeepney.low_level import Message + +from sampletones_application.utils.file_dialogs.backends.portal.variant import Variant + +SUCCESS_CODE: Final[int] = 0 +URIS_RESULT: Final[str] = "uris" +CURRENT_FILTER_RESULT: Final[str] = "current_filter" + + +@dataclass(frozen=True) +class ChooserResult: + """ + What a file-chooser dialog answered with. + + ``uris`` carries the chosen locations in the dialog's own order. ``filter_label`` is the + label of the type its selector stood on, present for a portal implementation that reports + the selection. + """ + + uris: Tuple[str, ...] + filter_label: Optional[str] + + @classmethod + def from_response(cls, response: Message) -> Optional[Self]: + """ + Reads what a dialog answered from the response signal carrying it. + + Args: + response: The ``Response`` signal the portal delivers on a request's object path. + + Returns: + Optional[Self]: What the dialog answered, ``None`` for a code other than success, + which is how the portal reports a dismissal. + """ + code, results = cast(Tuple[int, Dict[str, Variant]], response.body) + if code != SUCCESS_CODE: + return None + + return cls( + uris=cls._uris(results), + filter_label=cls._filter_label(results), + ) + + @staticmethod + def _uris(results: Dict[str, Variant]) -> Tuple[str, ...]: + uris = results.get(URIS_RESULT) + if uris is None: + return () + + return tuple(cast(List[str], uris[1])) + + @staticmethod + def _filter_label(results: Dict[str, Variant]) -> Optional[str]: + """The label of the type the dialog stood on, as the portal reports the whole filter back.""" + reported = results.get(CURRENT_FILTER_RESULT) + if reported is None: + return None + + label, _patterns = cast(Tuple[str, List[Tuple[int, str]]], reported[1]) + return label diff --git a/src/sampletones_application/utils/file_dialogs/backends/portal/variant.py b/src/sampletones_application/utils/file_dialogs/backends/portal/variant.py new file mode 100644 index 00000000..d1d2fb0b --- /dev/null +++ b/src/sampletones_application/utils/file_dialogs/backends/portal/variant.py @@ -0,0 +1,7 @@ +from typing import Final, Tuple + +Variant = Tuple[str, object] + +STRING_SIGNATURE: Final[str] = "s" +BYTES_SIGNATURE: Final[str] = "ay" +BOOLEAN_SIGNATURE: Final[str] = "b" diff --git a/src/sampletones_application/utils/file_dialogs/backends/tkinter.py b/src/sampletones_application/utils/file_dialogs/backends/tkinter.py new file mode 100644 index 00000000..50c4d903 --- /dev/null +++ b/src/sampletones_application/utils/file_dialogs/backends/tkinter.py @@ -0,0 +1,89 @@ +from pathlib import Path +from tkinter import Tk, filedialog +from typing import Callable, List, Optional, Tuple + +from sampletones_application.utils.file_dialogs.destination import ( + SaveDestination, + untyped_destination, +) +from sampletones_application.utils.file_dialogs.filter import FileFilter +from sampletones_shared.utils.system.paths import normalize_path + + +class TkinterBackend: + """ + File dialogs backed by ``tkinter.filedialog``. + + Tk renders the platform's native dialog on Windows and macOS, which makes this the + backend there; on Linux it is the last resort when neither kdialog nor zenity is + installed. Every offered type reaches the dialog's type selector as its own entry. + Each call raises a transient hidden root so the dialog owns no lasting window. + """ + + def open_file( + self, + *, + title: str, + initial_directory: Optional[Path], + filters: Tuple[FileFilter, ...], + ) -> Optional[Path]: + return self._run( + lambda: filedialog.askopenfilename( + title=title, + initialdir=self._initial_directory(initial_directory), + filetypes=self._filetypes(filters), + ) + ) + + def save_file( + self, + *, + title: str, + initial_directory: Optional[Path], + suggested_name: Optional[str], + filters: Tuple[FileFilter, ...], + ) -> Optional[SaveDestination]: + return untyped_destination( + self._run( + lambda: filedialog.asksaveasfilename( + title=title, + initialdir=self._initial_directory(initial_directory), + initialfile=suggested_name or "", + filetypes=self._filetypes(filters), + ) + ) + ) + + def select_directory( + self, + *, + title: str, + initial_directory: Optional[Path], + ) -> Optional[Path]: + return self._run( + lambda: filedialog.askdirectory( + title=title, + initialdir=self._initial_directory(initial_directory), + ) + ) + + @staticmethod + def _initial_directory(initial_directory: Optional[Path]) -> Optional[str]: + return str(initial_directory) if initial_directory is not None else None + + @staticmethod + def _filetypes( + filters: Tuple[FileFilter, ...], + ) -> List[Tuple[str, Tuple[str, ...]]]: + return [(file_filter.label, file_filter.patterns) for file_filter in filters] + + @staticmethod + def _run(dialog: Callable[[], str]) -> Optional[Path]: + root = Tk() + root.withdraw() + try: + selection = dialog() + finally: + root.destroy() + + return normalize_path(selection) diff --git a/src/sampletones_application/utils/file_dialogs/backends/zenity.py b/src/sampletones_application/utils/file_dialogs/backends/zenity.py new file mode 100644 index 00000000..2402c507 --- /dev/null +++ b/src/sampletones_application/utils/file_dialogs/backends/zenity.py @@ -0,0 +1,91 @@ +import os +from pathlib import Path +from typing import List, Optional, Tuple + +from sampletones_application.utils.file_dialogs.backends.command import run_dialog_command +from sampletones_application.utils.file_dialogs.destination import ( + SaveDestination, + untyped_destination, +) +from sampletones_application.utils.file_dialogs.filter import FileFilter + + +class ZenityBackend: + """ + File dialogs backed by GNOME's ``zenity`` (GTK). + + Every offered type reaches the file-type selector as its own entry, so each accepted + extension is named on screen. GTK selects among them to narrow what the browser lists, + and reports the name that was typed; the extension is guaranteed by the API layer. + """ + + def open_file( + self, + *, + title: str, + initial_directory: Optional[Path], + filters: Tuple[FileFilter, ...], + ) -> Optional[Path]: + command = ["zenity", "--file-selection", "--title", title] + command += self._filename_arguments(initial_directory, None) + command += self._filter_arguments(filters) + return run_dialog_command(command) + + def save_file( + self, + *, + title: str, + initial_directory: Optional[Path], + suggested_name: Optional[str], + filters: Tuple[FileFilter, ...], + ) -> Optional[SaveDestination]: + command = [ + "zenity", + "--file-selection", + "--save", + "--confirm-overwrite", + "--title", + title, + ] + command += self._filename_arguments(initial_directory, suggested_name) + command += self._filter_arguments(filters) + return untyped_destination(run_dialog_command(command)) + + def select_directory( + self, + *, + title: str, + initial_directory: Optional[Path], + ) -> Optional[Path]: + command = [ + "zenity", + "--file-selection", + "--directory", + "--title", + title, + ] + command += self._filename_arguments(initial_directory, None) + return run_dialog_command(command) + + @staticmethod + def _filename_arguments( + initial_directory: Optional[Path], + suggested_name: Optional[str], + ) -> List[str]: + if initial_directory is None and not suggested_name: + return [] + + base = initial_directory if initial_directory is not None else Path.home() + if suggested_name: + return ["--filename", str(base / suggested_name)] + + return ["--filename", f"{base}{os.sep}"] + + @staticmethod + def _filter_arguments(filters: Tuple[FileFilter, ...]) -> List[str]: + arguments: List[str] = [] + for file_filter in filters: + patterns = " ".join(file_filter.patterns) + arguments += ["--file-filter", f"{file_filter.label} | {patterns}"] + + return arguments diff --git a/src/sampletones_application/utils/file_dialogs/destination.py b/src/sampletones_application/utils/file_dialogs/destination.py new file mode 100644 index 00000000..09ddcb06 --- /dev/null +++ b/src/sampletones_application/utils/file_dialogs/destination.py @@ -0,0 +1,37 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + +from sampletones_application.utils.file_dialogs.filter import FileFilter + + +@dataclass(frozen=True) +class SaveDestination: + """ + Where a save dialog was told to write, and the file type it was chosen under. + + A dialog whose type selector reports the active type carries it in ``file_type``, one of the + types the dialog was asked to offer, which is what lets the API layer settle the extension + from the type the user picked. A dialog that answers with a name alone leaves it ``None``, + and the extension then follows from the name and the type the dialog opened on. + """ + + path: Path + file_type: Optional[FileFilter] + + +def untyped_destination(path: Optional[Path]) -> Optional[SaveDestination]: + """ + Returns the destination a dialog answering with a name alone gives, for the name it answered. + + Args: + path: The name the dialog answered with, or ``None`` once it was dismissed. + + Returns: + Optional[SaveDestination]: The destination carrying that name, or ``None`` for a + dismissed dialog. + """ + if path is None: + return None + + return SaveDestination(path=path, file_type=None) diff --git a/src/sampletones_application/utils/file_dialogs/filter.py b/src/sampletones_application/utils/file_dialogs/filter.py index 2dbfe09a..beec2bdb 100644 --- a/src/sampletones_application/utils/file_dialogs/filter.py +++ b/src/sampletones_application/utils/file_dialogs/filter.py @@ -1,11 +1,12 @@ from dataclasses import dataclass -from typing import Iterable, Tuple +from itertools import chain +from typing import Iterable, Optional, Tuple @dataclass(frozen=True) class FileFilter: """ - An extension filter offered by a native file dialog. + One file type offered by a native file dialog. Carries a human-readable ``name`` and the ``*``-prefixed glob ``patterns`` it matches. Each backend renders these into its own filter syntax; ``label`` is the @@ -15,6 +16,32 @@ class FileFilter: name: str patterns: Tuple[str, ...] + @classmethod + def for_extensions( + cls, + name: str, + extensions: Iterable[str], + ) -> "FileFilter": + """ + Returns the type matching ``extensions``, shown under ``name``. + + Accepts bare or already-globbed extensions, so a caller names the extensions it + writes and the glob form stays an implementation detail of the dialog layer. + + Args: + name: The human-readable name the dialog shows this type under. + extensions: The extensions the type matches, leading dot included. + + Returns: + FileFilter: The type a dialog offers for those extensions. + """ + return cls(name=name, patterns=normalize_extensions(extensions)) + + @property + def extensions(self) -> Tuple[str, ...]: + """The extensions this type matches, leading dot included.""" + return tuple(pattern.removeprefix("*") for pattern in self.patterns) + @property def label(self) -> str: """ @@ -39,3 +66,29 @@ def normalize_extensions(extensions: Iterable[str]) -> Tuple[str, ...]: ``"*.stp"`` for each, so every backend receives a uniform pattern form. """ return tuple(f"*{extension.removeprefix('*')}" for extension in extensions) + + +def merge_filters(filters: Tuple[FileFilter, ...]) -> Optional[FileFilter]: + """ + Returns the one type a dialog limited to a single filter offers. + + A dialog that takes one filter still accepts every type: the names join into one label + and the patterns gather behind it, so each accepted extension is named on screen and + every matching file stays reachable in the browser. One type passes through as it is, + which is the form that lets a dialog fill its extension in on its own. + + Args: + filters: The types the dialog was asked to offer. + + Returns: + Optional[FileFilter]: The single type to offer, or ``None`` when none was asked for. + """ + if not filters: + return None + + if len(filters) == 1: + return filters[0] + + names = ", ".join(file_filter.name for file_filter in filters if file_filter.name) + patterns = chain.from_iterable(file_filter.patterns for file_filter in filters) + return FileFilter(name=names, patterns=tuple(dict.fromkeys(patterns))) diff --git a/src/sampletones_application/utils/file_dialogs/kdialog.py b/src/sampletones_application/utils/file_dialogs/kdialog.py deleted file mode 100644 index ed9faf42..00000000 --- a/src/sampletones_application/utils/file_dialogs/kdialog.py +++ /dev/null @@ -1,95 +0,0 @@ -import subprocess -from pathlib import Path -from typing import List, Optional - -from sampletones_application.utils.file_dialogs.filter import FileFilter -from sampletones_shared.utils.system.paths import normalize_path - - -class KDialogBackend: - """ - File dialogs backed by KDE's ``kdialog`` (Qt). - - ``kdialog`` activates the supplied filter, so the file-type selector opens on the - chosen type. - """ - - def open_file( - self, - *, - title: str, - initial_directory: Optional[Path], - file_filter: Optional[FileFilter], - ) -> Optional[Path]: - command = [ - "kdialog", - "--getopenfilename", - _start_location(initial_directory), - ] - command += _filter_arguments(file_filter) - command += ["--title", title] - return _run(command) - - def save_file( - self, - *, - title: str, - initial_directory: Optional[Path], - suggested_name: Optional[str], - file_filter: Optional[FileFilter], - ) -> Optional[Path]: - command = [ - "kdialog", - "--getsavefilename", - _start_location( - initial_directory, - suggested_name, - ), - ] - command += _filter_arguments(file_filter) - command += ["--title", title] - return _run(command) - - def select_directory( - self, - *, - title: str, - initial_directory: Optional[Path], - ) -> Optional[Path]: - command = [ - "kdialog", - "--getexistingdirectory", - _start_location(initial_directory), - "--title", - title, - ] - return _run(command) - - -def _start_location( - initial_directory: Optional[Path], - suggested_name: Optional[str] = None, -) -> str: - base = initial_directory if initial_directory is not None else Path.home() - if suggested_name: - return str(base / suggested_name) - - return str(base) - - -def _filter_arguments(file_filter: Optional[FileFilter]) -> List[str]: - if file_filter is None: - return [] - - patterns = " ".join(file_filter.patterns) - return [f"{patterns}|{file_filter.label}"] - - -def _run(command: List[str]) -> Optional[Path]: - result = subprocess.run( - command, - capture_output=True, - text=True, - check=False, - ) - return normalize_path(result.stdout.strip()) diff --git a/src/sampletones_application/utils/file_dialogs/protocol.py b/src/sampletones_application/utils/file_dialogs/protocol.py new file mode 100644 index 00000000..95e80f35 --- /dev/null +++ b/src/sampletones_application/utils/file_dialogs/protocol.py @@ -0,0 +1,45 @@ +from pathlib import Path +from typing import Optional, Protocol, Tuple + +from sampletones_application.utils.file_dialogs.destination import SaveDestination +from sampletones_application.utils.file_dialogs.filter import FileFilter + + +class FileDialogBackend(Protocol): + """ + A native file-dialog implementation for one platform or desktop tool. + + An implementation drives a system dialog (the desktop portal, kdialog, zenity) or + ``tkinter`` and returns the chosen path, yielding ``None`` when the user cancels. The + selector in ``selection`` picks the implementation that fits the running environment. + + ``filters`` carries the types the dialog offers, in the order they are shown. Each + implementation renders as many of them as its dialog accepts, so a caller states the + types it writes once and every backend shows what it can. A save answers with a + ``SaveDestination``, which carries the type the user selected for implementations whose + dialog reports it. + """ + + def open_file( + self, + *, + title: str, + initial_directory: Optional[Path], + filters: Tuple[FileFilter, ...], + ) -> Optional[Path]: ... + + def save_file( + self, + *, + title: str, + initial_directory: Optional[Path], + suggested_name: Optional[str], + filters: Tuple[FileFilter, ...], + ) -> Optional[SaveDestination]: ... + + def select_directory( + self, + *, + title: str, + initial_directory: Optional[Path], + ) -> Optional[Path]: ... diff --git a/src/sampletones_application/utils/file_dialogs/selection.py b/src/sampletones_application/utils/file_dialogs/selection.py index 5c209a94..2859c5ad 100644 --- a/src/sampletones_application/utils/file_dialogs/selection.py +++ b/src/sampletones_application/utils/file_dialogs/selection.py @@ -3,15 +3,16 @@ import shutil from typing import Final, Optional -from sampletones_application.utils.file_dialogs.backend import FileDialogBackend -from sampletones_application.utils.file_dialogs.kdialog import KDialogBackend -from sampletones_application.utils.file_dialogs.zenity import ZenityBackend +from sampletones_application.utils.file_dialogs.backends.kdialog import KDialogBackend +from sampletones_application.utils.file_dialogs.backends.zenity import ZenityBackend +from sampletones_application.utils.file_dialogs.protocol import FileDialogBackend from sampletones_shared.exceptions import FileDialogUnavailableError from sampletones_shared.utils.system.system import System KDIALOG: Final[str] = "kdialog" ZENITY: Final[str] = "zenity" TKINTER_MODULE: Final[str] = "tkinter" +JEEPNEY_MODULE: Final[str] = "jeepney" DESKTOP_ENVIRONMENT_VARIABLE: Final[str] = "XDG_CURRENT_DESKTOP" KDE_DESKTOP: Final[str] = "KDE" @@ -23,10 +24,10 @@ def select_file_dialog_backend() -> FileDialogBackend: """ Returns the file-dialog backend that fits the running environment. - On Linux the choice follows the desktop environment and installed tools, with ``tkinter`` as - the last resort; on other platforms ``tkinter`` drives the native dialog. Availability is - probed for each candidate, so an environment lacking Tk opens dialogs through the desktop - tools instead. + On Linux the choice follows the desktop portal, then the desktop environment and its installed + tools, with ``tkinter`` as the last resort; on other platforms ``tkinter`` drives the native + dialog. Availability is probed for each candidate, so an environment lacking Tk opens dialogs + through the desktop tools instead. Raises: FileDialogUnavailableError: If the environment provides no usable backend. @@ -46,6 +47,17 @@ def select_file_dialog_backend() -> FileDialogBackend: def _select_linux_backend() -> Optional[FileDialogBackend]: + """ + Returns the Linux backend to open dialogs with, in order of what each dialog can express. + + The desktop portal comes first: it lists every offered file type in its selector and reports + the one the user picked, so a caller offering several types learns which was chosen. Behind + it stand the desktop's own command-line tools, and Tk last. + """ + portal = _portal_backend() + if portal is not None: + return portal + kdialog = KDialogBackend() if shutil.which(KDIALOG) is not None else None zenity = ZenityBackend() if shutil.which(ZENITY) is not None else None @@ -59,6 +71,21 @@ def _select_linux_backend() -> Optional[FileDialogBackend]: return preferred or alternative or _tkinter_backend() +def _portal_backend() -> Optional[FileDialogBackend]: + """ + Returns a portal-backed implementation once ``jeepney`` is installed and a portal answers. + + ``jeepney`` is declared for Linux alone, so its presence is probed before the portal module + is imported, which leaves application startup on every other platform independent of it. + """ + if importlib.util.find_spec(JEEPNEY_MODULE) is None: + return None + + from sampletones_application.utils.file_dialogs.backends.portal.backend import portal_backend + + return portal_backend() + + def _tkinter_backend() -> Optional[FileDialogBackend]: """ Returns a Tk-backed implementation once the ``tkinter`` module is importable. @@ -70,7 +97,7 @@ def _tkinter_backend() -> Optional[FileDialogBackend]: if importlib.util.find_spec(TKINTER_MODULE) is None: return None - from sampletones_application.utils.file_dialogs.tkinter_backend import TkinterBackend + from sampletones_application.utils.file_dialogs.backends.tkinter import TkinterBackend return TkinterBackend() diff --git a/src/sampletones_application/utils/file_dialogs/tkinter_backend.py b/src/sampletones_application/utils/file_dialogs/tkinter_backend.py deleted file mode 100644 index f158666e..00000000 --- a/src/sampletones_application/utils/file_dialogs/tkinter_backend.py +++ /dev/null @@ -1,86 +0,0 @@ -from pathlib import Path -from tkinter import Tk, filedialog -from typing import Callable, List, Optional, Tuple - -from sampletones_application.utils.file_dialogs.filter import FileFilter -from sampletones_shared.utils.system.paths import normalize_path - - -class TkinterBackend: - """ - File dialogs backed by ``tkinter.filedialog``. - - Tk renders the platform's native dialog on Windows and macOS, which makes this the - backend there; on Linux it is the last resort when neither kdialog nor zenity is - installed. Each call raises a transient hidden root so the dialog owns no lasting - window. - """ - - def open_file( - self, - *, - title: str, - initial_directory: Optional[Path], - file_filter: Optional[FileFilter], - ) -> Optional[Path]: - return _run( - lambda: filedialog.askopenfilename( - title=title, - initialdir=_initial_directory(initial_directory), - filetypes=_filetypes(file_filter), - ) - ) - - def save_file( - self, - *, - title: str, - initial_directory: Optional[Path], - suggested_name: Optional[str], - file_filter: Optional[FileFilter], - ) -> Optional[Path]: - return _run( - lambda: filedialog.asksaveasfilename( - title=title, - initialdir=_initial_directory(initial_directory), - initialfile=suggested_name or "", - filetypes=_filetypes(file_filter), - ) - ) - - def select_directory( - self, - *, - title: str, - initial_directory: Optional[Path], - ) -> Optional[Path]: - return _run( - lambda: filedialog.askdirectory( - title=title, - initialdir=_initial_directory(initial_directory), - ) - ) - - -def _initial_directory(initial_directory: Optional[Path]) -> Optional[str]: - return str(initial_directory) if initial_directory is not None else None - - -def _filetypes( - file_filter: Optional[FileFilter], -) -> List[Tuple[str, Tuple[str, ...]]]: - if file_filter is None: - return [] - - return [(file_filter.label, tuple(file_filter.patterns))] - - -def _run(dialog: Callable[[], str]) -> Optional[Path]: - root = Tk() - root.withdraw() - try: - selection = dialog() - finally: - root.destroy() - - return normalize_path(selection) diff --git a/src/sampletones_application/utils/file_dialogs/zenity.py b/src/sampletones_application/utils/file_dialogs/zenity.py deleted file mode 100644 index 80ebfa44..00000000 --- a/src/sampletones_application/utils/file_dialogs/zenity.py +++ /dev/null @@ -1,97 +0,0 @@ -import os -import subprocess -from pathlib import Path -from typing import List, Optional - -from sampletones_application.utils.file_dialogs.filter import FileFilter -from sampletones_shared.utils.system.paths import normalize_path - - -class ZenityBackend: - """ - File dialogs backed by GNOME's ``zenity`` (GTK). - - The named filter appears in the file-type selector. ``zenity`` lists the filter - but leaves the selector on its "(None)" entry, since its command line offers no - way to pre-select a filter; the extension is still guaranteed by the API layer. - """ - - def open_file( - self, - *, - title: str, - initial_directory: Optional[Path], - file_filter: Optional[FileFilter], - ) -> Optional[Path]: - command = ["zenity", "--file-selection", "--title", title] - command += _filename_arguments(initial_directory, None) - command += _filter_arguments(file_filter) - return _run(command) - - def save_file( - self, - *, - title: str, - initial_directory: Optional[Path], - suggested_name: Optional[str], - file_filter: Optional[FileFilter], - ) -> Optional[Path]: - command = [ - "zenity", - "--file-selection", - "--save", - "--confirm-overwrite", - "--title", - title, - ] - command += _filename_arguments(initial_directory, suggested_name) - command += _filter_arguments(file_filter) - return _run(command) - - def select_directory( - self, - *, - title: str, - initial_directory: Optional[Path], - ) -> Optional[Path]: - command = [ - "zenity", - "--file-selection", - "--directory", - "--title", - title, - ] - command += _filename_arguments(initial_directory, None) - return _run(command) - - -def _filename_arguments( - initial_directory: Optional[Path], - suggested_name: Optional[str], -) -> List[str]: - if initial_directory is None and not suggested_name: - return [] - - base = initial_directory if initial_directory is not None else Path.home() - if suggested_name: - return ["--filename", str(base / suggested_name)] - - return ["--filename", f"{base}{os.sep}"] - - -def _filter_arguments(file_filter: Optional[FileFilter]) -> List[str]: - if file_filter is None: - return [] - - patterns = " ".join(file_filter.patterns) - return ["--file-filter", f"{file_filter.label} | {patterns}"] - - -def _run(command: List[str]) -> Optional[Path]: - result = subprocess.run( - command, - capture_output=True, - text=True, - check=False, - ) - return normalize_path(result.stdout.strip()) diff --git a/src/sampletones_application/utils/gui/shortcuts/ids.py b/src/sampletones_application/utils/gui/shortcuts/ids.py index 77116b53..966fd475 100644 --- a/src/sampletones_application/utils/gui/shortcuts/ids.py +++ b/src/sampletones_application/utils/gui/shortcuts/ids.py @@ -2,6 +2,7 @@ from typing import Dict, Final from sampletones_core.constants.enums import GeneratorName +from sampletones_core.trackers.format import TrackerFormat class ShortcutId(Enum): @@ -10,7 +11,8 @@ class ShortcutId(Enum): SAVE_PROJECT = "SaveProject" SAVE_PROJECT_AS = "SaveProjectAs" PROJECT_PROPERTIES = "ProjectProperties" - EXPORT_PROJECT_MODULE = "ExportProjectModule" + EXPORT_PROJECT_FAMITRACKER = "ExportProjectFamiTracker" + EXPORT_PROJECT_BITPHASE = "ExportProjectBitphase" CLOSE_PROJECT = "CloseProject" EXIT = "Exit" UNDO = "Undo" @@ -24,7 +26,8 @@ class ShortcutId(Enum): SAVE_RECONSTRUCTION_AS = "SaveReconstructionAs" CLOSE_RECONSTRUCTION = "CloseReconstruction" EXPORT_RECONSTRUCTION_WAV = "ExportReconstructionWav" - EXPORT_RECONSTRUCTION_INSTRUMENTS = "ExportReconstructionInstruments" + EXPORT_INSTRUMENTS_FAMITRACKER = "ExportInstrumentsFamiTracker" + EXPORT_INSTRUMENTS_BITPHASE_PRESET = "ExportInstrumentsBitphasePreset" ADD_RECONSTRUCTION_TO_SEQUENCER = "AddReconstructionToSequencer" OPEN_RECONSTRUCTION_IN_EXPLORER = "OpenReconstructionInExplorer" LOCATE_ORIGINAL_AUDIO = "LocateOriginalAudio" @@ -54,3 +57,13 @@ class ShortcutId(Enum): GeneratorName.TRIANGLE: ShortcutId.TOGGLE_CHANNEL_TRIANGLE, GeneratorName.NOISE: ShortcutId.TOGGLE_CHANNEL_NOISE, } + +PROJECT_EXPORT_SHORTCUT_IDS: Final[Dict[TrackerFormat, ShortcutId]] = { + TrackerFormat.FAMITRACKER: ShortcutId.EXPORT_PROJECT_FAMITRACKER, + TrackerFormat.BITPHASE: ShortcutId.EXPORT_PROJECT_BITPHASE, +} + +SAMPLE_EXPORT_SHORTCUT_IDS: Final[Dict[TrackerFormat, ShortcutId]] = { + TrackerFormat.FAMITRACKER: ShortcutId.EXPORT_INSTRUMENTS_FAMITRACKER, + TrackerFormat.BITPHASE_PRESET: ShortcutId.EXPORT_INSTRUMENTS_BITPHASE_PRESET, +} diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 93e51833..a94a96cd 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -35,7 +35,8 @@ global.dialog.title.load_unsaved_reconstruction: "Load reconstruction" global.dialog.title.save_project: "Save project" global.dialog.title.project_saved: "Project saved" global.dialog.title.export_module: "Export FamiTracker module" -global.dialog.title.module_exported: "Module exported" +global.dialog.title.export_bitphase_project: "Export Bitphase project" +global.dialog.title.project_exported: "Project exported" global.dialog.title.new_unsaved_project: "New project" global.dialog.title.open_unsaved_project: "Open project" global.dialog.title.close_unsaved_project: "Close project" @@ -49,7 +50,9 @@ global.dialog.title.about: "About" global.dialog.filter.project: "Project files" global.dialog.filter.reconstruction: "Reconstruction files" global.dialog.filter.module: "FamiTracker module" -global.dialog.filter.instrument: "FamiTracker instrument" +global.dialog.filter.famitracker_instrument: "FamiTracker instrument" +global.dialog.filter.bitphase_project: "Bitphase project" +global.dialog.filter.bitphase_preset: "Bitphase instrument preset" global.dialog.filter.config: "Configuration files" global.dialog.filter.audio: "Audio files" global.dialog.filter.wave: "WAV audio" @@ -70,6 +73,8 @@ global.dialog.message.project_saved_successfully: "Project saved successfully." global.dialog.message.project_save_failed: "Failed to save project." global.dialog.message.project_exported_successfully: "FamiTracker module exported successfully." global.dialog.message.project_export_failed: "Failed to export FamiTracker module." +global.dialog.message.bitphase_project_exported_successfully: "Bitphase project exported successfully." +global.dialog.message.bitphase_project_export_failed: "Failed to export Bitphase project." global.dialog.message.new_unsaved_project: "The current project has unsaved changes. Do you want to save it before starting a new one?" global.dialog.message.open_unsaved_project: "The current project has unsaved changes. Do you want to save it before opening another?" global.dialog.message.close_unsaved_project: "The current project has unsaved changes. Do you want to save it before closing?" @@ -160,7 +165,9 @@ global.menu.label.item_file_open_project: "Open project..." global.menu.label.item_file_save_project: "Save project" global.menu.label.item_file_save_project_as: "Save project as..." global.menu.label.item_file_project_properties: "Project properties..." -global.menu.label.item_file_export_module: "Export FamiTracker module..." +global.menu.label.group_file_export: "Export" +global.menu.label.item_file_export_famitracker: "FamiTracker module..." +global.menu.label.item_file_export_bitphase: "Bitphase project..." global.menu.label.item_file_close_project: "Close project" global.menu.label.item_file_exit: "Exit" global.menu.label.group_edit: "Edit" @@ -176,7 +183,9 @@ global.menu.label.item_reconstruction_save: "Save reconstruction" global.menu.label.item_reconstruction_save_as: "Save reconstruction as..." global.menu.label.item_reconstruction_close: "Close reconstruction" global.menu.label.item_reconstruction_export_wav: "Export to WAV..." -global.menu.label.item_reconstruction_export_instruments: "Export FamiTracker instruments..." +global.menu.label.group_reconstruction_export_instruments: "Export instruments" +global.menu.label.item_reconstruction_export_instruments_famitracker: "FamiTracker instruments..." +global.menu.label.item_reconstruction_export_instruments_bitphase_preset: "Bitphase presets..." global.menu.label.group_playback: "Playback" global.menu.label.item_playback_play: "Play" global.menu.label.item_playback_pause: "Pause" @@ -384,8 +393,7 @@ reconstructions.reconstruction.message.export_wav_failed: "Reconstruction failed # Reconstructions tab — Instruments # ============================================================================= reconstructions.instruments.label.section: "Instruments" -reconstructions.instruments.label.export_instrument_button: "Export FamiTracker instrument" -reconstructions.instruments.label.export_instruments_button: "Export FamiTracker instruments" +reconstructions.instruments.label.export_instrument_button: "Export instrument..." reconstructions.instruments.label.copy_button: "Copy" reconstructions.instruments.label.pitch_label: "Pitch" reconstructions.instruments.label.hi_pitch_label: "Hi-pitch" @@ -398,11 +406,11 @@ reconstructions.instruments.message.status_input_pitch: "Ctrl + click to type va reconstructions.instruments.message.status_input_period: "Ctrl + click to type value. Enter period name (e.g. \"4-#\") or integer value (4)." reconstructions.instruments.message.status_bar: "Click to change {instrument_feature}. Scroll to zoom horizontally. Right-click for more options." reconstructions.instruments.message.status_sequence: "Edit and press Enter to change {instrument_feature}." -reconstructions.instruments.message.status_sequence_too_long: "{instrument_feature}: {items} items, truncated to {limit} on export." +reconstructions.instruments.message.status_sequence_too_long: "{instrument_feature}: {items} items, truncated to {limit} on a FamiTracker export." reconstructions.instruments.message.status_copy_sequence: "Copy sequence to clipboard." reconstructions.instruments.message.status_generator_toggle: "Click to turn {on_or_off} {generator_name}." reconstructions.instruments.message.status_generator_not_available: "{generator_name} is not available." -reconstructions.instruments.message.status_export_instrument: "Export the {generator} generator's instrument as a FamiTracker instrument file." +reconstructions.instruments.message.status_export_instrument: "Writes the {generator} generator's instrument, for the tracker the chosen extension names." reconstructions.instruments.message.export_instrument_success: "Instrument saved successfully." reconstructions.instruments.message.export_instruments_success: "Reconstruction instruments saved successfully." reconstructions.instruments.message.export_instrument_truncated: "The envelope was truncated from {source_frames} to {frames} frames." @@ -412,8 +420,8 @@ reconstructions.instruments.message.export_instruments_failed: "Failed to export reconstructions.instruments.title.export_status_dialog: "Export status" reconstructions.instruments.title.not_loaded_dialog: "Reconstruction not loaded" reconstructions.instruments.title.export_wav_dialog: "Export WAV" -reconstructions.instruments.title.export_instrument_dialog: "Export FamiTracker instrument" -reconstructions.instruments.title.export_instruments_dialog: "Export FamiTracker instruments" +reconstructions.instruments.title.export_instrument_dialog: "Export instrument" +reconstructions.instruments.title.export_instruments_dialog: "Export instruments" reconstructions.instruments.template.initial_pitch_tooltip_template: "Enter the initial {} by name (e.g. {}) or value ({})." # ============================================================================= diff --git a/src/sampletones_core/calibration/corpus/writer.py b/src/sampletones_core/calibration/corpus/writer.py index 2823e7ed..f7367a6b 100644 --- a/src/sampletones_core/calibration/corpus/writer.py +++ b/src/sampletones_core/calibration/corpus/writer.py @@ -3,6 +3,7 @@ from sampletones_core.audio.io import write_wave from sampletones_core.paths import EXT_FILE_WAVE +from sampletones_shared.utils.system.paths import get_filename from .item import CorpusItem @@ -26,7 +27,7 @@ def write_corpus( directory.mkdir(parents=True, exist_ok=True) paths: Dict[str, Path] = {} for item in items: - path = directory / f"{item.name}{EXT_FILE_WAVE}" + path = directory / get_filename(item.name, EXT_FILE_WAVE) write_wave(path, sample_rate, item.audio) paths[item.name] = path diff --git a/src/sampletones_core/famitracker/sequences/__init__.py b/src/sampletones_core/compatibility/__init__.py similarity index 100% rename from src/sampletones_core/famitracker/sequences/__init__.py rename to src/sampletones_core/compatibility/__init__.py diff --git a/src/sampletones_core/compatibility/kind.py b/src/sampletones_core/compatibility/kind.py new file mode 100644 index 00000000..af1d6c8d --- /dev/null +++ b/src/sampletones_core/compatibility/kind.py @@ -0,0 +1,7 @@ +from enum import StrEnum, auto + + +class ObjectKind(StrEnum): + LIBRARY = auto() + RECONSTRUCTION = auto() + PROJECT = auto() diff --git a/src/sampletones_core/compatibility/update.py b/src/sampletones_core/compatibility/update.py new file mode 100644 index 00000000..fdc41e65 --- /dev/null +++ b/src/sampletones_core/compatibility/update.py @@ -0,0 +1,10 @@ +from typing import NamedTuple + +from sampletones_core.compatibility.kind import ObjectKind +from sampletones_shared.deployment.version import Version + + +class VersionUpdate(NamedTuple): + kind: ObjectKind + base: Version + target: Version diff --git a/src/sampletones_core/data/model.py b/src/sampletones_core/data/model.py index f8c2568b..50112c9c 100644 --- a/src/sampletones_core/data/model.py +++ b/src/sampletones_core/data/model.py @@ -87,6 +87,7 @@ def load(cls, path: Pathlike, fast: bool = True) -> Self: def _construct(cls, fast: bool = True, **data: Any) -> Self: if fast: return cls.model_construct(**data) + return cls(**data) def serialize_inner(self) -> SerializedData: @@ -95,6 +96,7 @@ def serialize_inner(self) -> SerializedData: value = getattr(self, field_name) annotation = field_info.annotation result[field_name] = self._pack_value(value, annotation, field_name) + return result @classmethod @@ -108,10 +110,18 @@ def deserialize_inner( for field_name, field_info in cls.model_fields.items(): annotation = field_info.annotation raw = data.get(field_name) - value = cls._unpack_value(raw, annotation, field_name, validation, fast) + value = cls._unpack_value( + raw, + annotation, + field_name, + validation, + fast, + ) if validation is not None: validation(value) + field_values[field_name] = value + return cls._construct(fast=fast, **field_values) def _pack_value(self, value: Any, annotation: Any, field_name: str) -> Any: @@ -126,7 +136,9 @@ def _pack_value(self, value: Any, annotation: Any, field_name: str) -> Any: if optional_inner is not None: if value is None: return None + return self._pack_value(value, optional_inner, field_name) + return self._pack_union(value, field_name) if isinstance(annotation, TypeVar): @@ -169,7 +181,9 @@ def _unpack_value( if optional_inner is not None: if raw is None: return None + return cls._unpack_value(raw, optional_inner, field_name, validation, fast) + return cls._unpack_union(raw, field_name) if isinstance(annotation, TypeVar): diff --git a/src/sampletones_core/exporters/exporter.py b/src/sampletones_core/exporters/exporter.py index 36ecfbf5..3999e668 100644 --- a/src/sampletones_core/exporters/exporter.py +++ b/src/sampletones_core/exporters/exporter.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from typing import Dict, Generic, Iterable, List, Optional, Union, cast +from typing import Dict, Final, Generic, List, Optional, Union, cast import numpy as np @@ -11,10 +11,12 @@ InstructionTypeUnion, ) from sampletones_core.types.feature import FeatureMap -from sampletones_shared.utils.arrays import trim +from sampletones_shared.utils.arrays import hold, trim from .feature import Features +EMPTY_ENVELOPE_VALUE: Final[int] = 0 + class Exporter(ABC, Generic[InstructionT]): """ @@ -35,16 +37,18 @@ class Exporter(ABC, Generic[InstructionT]): def to_features( self, instructions: List[InstructionT], + initial_pitch: int, ) -> Features: """Converts an instruction sequence into its :class:`Features`. Args: instructions: The channel's per-frame instructions. + initial_pitch: Reference pitch the arpeggio envelope is measured against. Returns: Features: The envelope representation of the sequence. """ - feature_map = self.get_feature_map(instructions) + feature_map = self.get_feature_map(instructions, initial_pitch) return self.from_feature_map_to_features(feature_map) @staticmethod @@ -77,23 +81,41 @@ def from_feature_map_to_features(feature_map: FeatureMap) -> Features: @classmethod @abstractmethod - def get_feature_map(cls, instructions: List[InstructionT]) -> FeatureMap: + def get_feature_map(cls, instructions: List[InstructionT], initial_pitch: int) -> FeatureMap: """Extracts the raw per-dimension feature arrays from an instruction sequence. Args: instructions: The channel's per-frame instructions. + initial_pitch: Reference pitch the arpeggio envelope is measured against. Returns: FeatureMap: The per-dimension arrays for this channel. """ + @classmethod + @abstractmethod + def derive_initial_pitch(cls, instructions: List[InstructionT]) -> int: + """Chooses the reference pitch an instruction sequence's arpeggio is measured against. + + The reference is chosen once, when a reconstruction is built, and stored alongside + the sequence. Every later export measures against that stored value, so editing the + arpeggio moves the frames around a base pitch that stays put. + + Args: + instructions: The channel's per-frame instructions. + + Returns: + int: The reference pitch for the sequence. + """ + @classmethod def from_features(cls, features: Features) -> List[InstructionT]: """Rebuilds the instruction sequence from a :class:`Features`. - Walks the envelopes frame by frame, reading each dimension's value (holding the - previous instruction's value past the end of a shorter envelope) and assembling - one instruction per frame. + Walks the envelopes frame by frame and assembles one instruction per frame. Every + envelope is read relative to itself — a dimension trimmed shorter than the sequence + holds its own final value over the remaining frames — so the arpeggio stays an + offset from ``initial_pitch`` for the whole sequence. Args: features: The envelope representation of a channel. @@ -101,38 +123,25 @@ def from_features(cls, features: Features) -> List[InstructionT]: Returns: List[InstructionT]: The reconstructed per-frame instructions. """ - features_map = features.feature_map initial_pitch = features.initial_pitch + envelopes: Dict[FeatureKey, np.ndarray] = { + key: cast(np.ndarray, value) + for key, value in features.feature_map.items() + if key != FeatureKey.INITIAL_PITCH and value is not None + } + max_length = max((len(array) for array in envelopes.values()), default=0) + instructions: List[InstructionT] = [] - last_instruction: Optional[InstructionT] = None - non_empty_arrays: Iterable[np.ndarray] = cast( - Iterable[np.ndarray], - filter(lambda obj: isinstance(obj, np.ndarray), features_map.values()), - ) - max_length = max(map(len, non_empty_arrays), default=0) for index in range(max_length): instruction_dictionary: Dict[str, Union[bool, int]] = {} - for key, array in features_map.items(): - if key == FeatureKey.INITIAL_PITCH or array is None: - continue - + for key, array in envelopes.items(): attribute = cls._remap_feature_key(key) if not attribute: continue - value = Exporter.get_value( - attribute, - cast(np.ndarray, array), - last_instruction, - index, - initial_value=initial_pitch if attribute == "pitch" else 0, - ) - - instruction_dictionary[attribute] = value + instruction_dictionary[attribute] = int(hold(array, index, default=EMPTY_ENVELOPE_VALUE)) - instruction = cls._features_dictionary_to_instruction(instruction_dictionary, initial_pitch) - instructions.append(instruction) - last_instruction = instruction + instructions.append(cls._features_dictionary_to_instruction(instruction_dictionary, initial_pitch)) return instructions @@ -163,56 +172,6 @@ def _infer_instruction_on(dictionary: Dict[str, Union[bool, int]]) -> bool: return True - @classmethod - def _handle_special_attributes( - cls, - attribute: InstructionFields, - value: int, - initial_value: int, - ) -> int: - if attribute == "pitch": - value -= initial_value - - return value - - @classmethod - def get_value( - cls, - attribute: InstructionFields, - array: Optional[np.ndarray], - last_instruction: Optional[InstructionT], - index: int, - initial_value: int = 0, - ) -> int: - """Reads one attribute's value for a given frame. - - Returns the array's value at ``index`` when present; past the array's end it - carries the previous instruction's value forward, and falls back to - ``initial_value`` when neither is available. - - Args: - attribute: The instruction field being read. - array: The dimension's envelope, or ``None``. - last_instruction: The instruction from the previous frame, if any. - index: The frame position to read. - initial_value: The value used when the array is empty or exhausted. - - Returns: - int: The attribute's value for the frame. - """ - if array is None or not array.size: - return initial_value - - if index < len(array): - return int(array[index]) - - if last_instruction is not None: - if hasattr(last_instruction, attribute): - value = int(getattr(last_instruction, attribute)) - return cls._handle_special_attributes(attribute, value, initial_value) - - return initial_value - @classmethod def _remap_feature_key(cls, feature_key: FeatureKey) -> Optional[InstructionFields]: if not hasattr(cls, "_ATTRIBUTE_MAP"): diff --git a/src/sampletones_core/exporters/feature.py b/src/sampletones_core/exporters/feature.py index 9cca660d..33634cce 100644 --- a/src/sampletones_core/exporters/feature.py +++ b/src/sampletones_core/exporters/feature.py @@ -1,16 +1,11 @@ from __future__ import annotations -from pathlib import Path from typing import Any, Dict, List, Optional, Tuple, cast import numpy as np from pydantic import BaseModel, ConfigDict from sampletones_core.constants.enums import FeatureKey -from sampletones_core.famitracker.fti import write_fti -from sampletones_core.famitracker.model.instrument import Instrument2A03 -from sampletones_core.famitracker.sequences.features import features_to_instrument_sequences -from sampletones_core.famitracker.sequences.truncation import SequenceTruncation from sampletones_core.types.feature import FeatureMap, FeatureValue @@ -19,13 +14,13 @@ class Features(BaseModel): The per-dimension envelopes describing one FamiTracker instrument. Each field is the frame-by-frame envelope for one dimension — volume, arpeggio, - pitch, hi-pitch, and duty cycle — alongside the ``initial_pitch`` the pitch envelope - is relative to. An optional dimension is absent when the channel does not use it. - The mapping interface (subscript, ``get``, ``keys``/``items``/``values``, ``in``) - exposes the envelopes keyed by :class:`FeatureKey`, passing over absent ones. + pitch, hi-pitch, and duty cycle — alongside the ``initial_pitch`` the arpeggio + envelope is relative to. An optional dimension is absent when the channel does not + use it. The mapping interface (subscript, ``get``, ``keys``/``items``/``values``, + ``in``) exposes the envelopes keyed by :class:`FeatureKey`, passing over absent ones. Attributes: - initial_pitch: Reference pitch the pitch envelope is measured against. + initial_pitch: Reference pitch the arpeggio envelope is measured against. volume: Volume envelope. arpeggio: Arpeggio (relative pitch) envelope. pitch: Pitch envelope, or ``None`` when unused. @@ -111,43 +106,3 @@ def frame_count(self) -> int: """The frame count the envelopes describe, taken from the longest populated dimension.""" arrays = (self.volume, self.arpeggio, self.pitch, self.hi_pitch, self.duty_cycle) return max((len(array) for array in arrays if array is not None), default=0) - - def save(self, filepath: Path, instrument_name: str) -> Optional[SequenceTruncation]: - """Writes the features to a FamiTracker instrument (``.fti``) file. - - Builds a single 2A03 instrument from the envelopes and serializes it. Envelopes - longer than a FamiTracker sequence holds reach the file as their opening frames, - which the return value reports. - - Args: - filepath: Destination path for the ``.fti`` file. - instrument_name: Name stored in the instrument. - - Returns: - Optional[SequenceTruncation]: The frames the sequence limit left out, and - ``None`` when the file carries every frame. - - Raises: - IOError: If the file cannot be written. - """ - sequences = features_to_instrument_sequences( - volume=self.volume, - arpeggio=self.arpeggio, - pitch=self.pitch, - hi_pitch=self.hi_pitch, - duty_cycle=self.duty_cycle, - loop=False, - ) - instrument = Instrument2A03(index=0, name=instrument_name, sequences=sequences) - try: - write_fti(filepath, instrument) - except ( - FileNotFoundError, - IOError, - OSError, - PermissionError, - IsADirectoryError, - ) as exception: - raise IOError(f"Failed to save features to '{filepath}': {exception}") from exception - - return SequenceTruncation.measure(self.frame_count) diff --git a/src/sampletones_core/exporters/implementation/noise.py b/src/sampletones_core/exporters/implementation/noise.py index 9833f8c2..5fe7867a 100644 --- a/src/sampletones_core/exporters/implementation/noise.py +++ b/src/sampletones_core/exporters/implementation/noise.py @@ -57,12 +57,24 @@ def extract_data(cls, instructions: List[NoiseInstruction]) -> Tuple[int, List[i return initial_period, periods, volumes, duty_cycles @classmethod - def get_feature_map(cls, instructions: List[NoiseInstruction]) -> FeatureMap: - initial_period, periods, volumes, duty_cycles = cls.extract_data(instructions) - arpeggio = (np.array(periods) - initial_period) % NUM_PERIODS + def derive_initial_pitch( + cls, + instructions: List[NoiseInstruction], + ) -> int: + initial_period, _, _, _ = cls.extract_data(instructions) + return initial_period + + @classmethod + def get_feature_map( + cls, + instructions: List[NoiseInstruction], + initial_pitch: int, + ) -> FeatureMap: + _, periods, volumes, duty_cycles = cls.extract_data(instructions) + arpeggio = (np.array(periods) - initial_pitch) % NUM_PERIODS return { - FeatureKey.INITIAL_PITCH: initial_period, + FeatureKey.INITIAL_PITCH: initial_pitch, FeatureKey.VOLUME: np.array(volumes).astype(np.int8), FeatureKey.ARPEGGIO: arpeggio.astype(np.int8), FeatureKey.DUTY_CYCLE: np.array(duty_cycles).astype(np.int8), diff --git a/src/sampletones_core/exporters/implementation/pulse.py b/src/sampletones_core/exporters/implementation/pulse.py index beca8175..00a4c99c 100644 --- a/src/sampletones_core/exporters/implementation/pulse.py +++ b/src/sampletones_core/exporters/implementation/pulse.py @@ -4,7 +4,7 @@ from sampletones_core.constants.enums import FeatureKey from sampletones_core.constants.general import MIN_PITCH -from sampletones_core.exporters.implementation.utils import center_pitches +from sampletones_core.exporters.implementation.utils import center_pitch from sampletones_core.generators import GeneratorTypeUnion, PulseGenerator from sampletones_core.instructions import ( InstructionFields, @@ -59,9 +59,18 @@ def extract_data(cls, instructions: List[PulseInstruction]) -> Tuple[int, List[i return initial_pitch, pitches, volumes, duty_cycles @classmethod - def get_feature_map(cls, instructions: List[PulseInstruction]) -> FeatureMap: - initial_pitch, pitches, volumes, duty_cycles = cls.extract_data(instructions) - initial_pitch, arpeggio = center_pitches(initial_pitch, pitches) + def derive_initial_pitch(cls, instructions: List[PulseInstruction]) -> int: + first_pitch, pitches, _, _ = cls.extract_data(instructions) + return center_pitch(first_pitch, pitches) + + @classmethod + def get_feature_map( + cls, + instructions: List[PulseInstruction], + initial_pitch: int, + ) -> FeatureMap: + _, pitches, volumes, duty_cycles = cls.extract_data(instructions) + arpeggio = np.array(pitches) - initial_pitch return { FeatureKey.INITIAL_PITCH: initial_pitch, diff --git a/src/sampletones_core/exporters/implementation/triangle.py b/src/sampletones_core/exporters/implementation/triangle.py index 9c470cd4..4f69ab58 100644 --- a/src/sampletones_core/exporters/implementation/triangle.py +++ b/src/sampletones_core/exporters/implementation/triangle.py @@ -4,7 +4,7 @@ from sampletones_core.constants.enums import FeatureKey from sampletones_core.constants.general import MAX_VOLUME, MIN_PITCH -from sampletones_core.exporters.implementation.utils import center_pitches +from sampletones_core.exporters.implementation.utils import center_pitch from sampletones_core.generators import GeneratorTypeUnion, TriangleGenerator from sampletones_core.instructions import ( InstructionFields, @@ -54,9 +54,21 @@ def extract_data(cls, instructions: List[TriangleInstruction]) -> Tuple[int, Lis return initial_pitch, pitches, volumes @classmethod - def get_feature_map(cls, instructions: List[TriangleInstruction]) -> FeatureMap: - initial_pitch, pitches, volumes = cls.extract_data(instructions) - initial_pitch, arpeggio = center_pitches(initial_pitch, pitches) + def derive_initial_pitch( + cls, + instructions: List[TriangleInstruction], + ) -> int: + first_pitch, pitches, _ = cls.extract_data(instructions) + return center_pitch(first_pitch, pitches) + + @classmethod + def get_feature_map( + cls, + instructions: List[TriangleInstruction], + initial_pitch: int, + ) -> FeatureMap: + _, pitches, volumes = cls.extract_data(instructions) + arpeggio = np.array(pitches) - initial_pitch return { FeatureKey.INITIAL_PITCH: initial_pitch, diff --git a/src/sampletones_core/exporters/implementation/utils.py b/src/sampletones_core/exporters/implementation/utils.py index 9e3d0565..b6d8182a 100644 --- a/src/sampletones_core/exporters/implementation/utils.py +++ b/src/sampletones_core/exporters/implementation/utils.py @@ -1,29 +1,32 @@ -from typing import List, Tuple +from typing import List import numpy as np -def center_pitches( +def center_pitch( initial_pitch: int, pitches: List[int], -) -> Tuple[int, np.ndarray]: +) -> int: """ - Re-centers a pitch sequence around the midpoint of its range. + Picks the pitch at the midpoint of a contour's range. - Shifts every pitch by the midpoint of its ``(min, max)`` range so the offsets - straddle zero, keeping an arpeggio's relative steps small around one center pitch. + Measuring a contour's offsets from the midpoint of its ``(min, max)`` range keeps an + arpeggio's relative steps small and straddling zero around one center pitch. An empty + contour keeps the reference where it is. Args: initial_pitch: Reference pitch the offsets are measured against. - pitches: Absolute pitches to re-center. + pitches: Absolute pitches the contour covers. Returns: - The center pitch (``initial_pitch`` plus the range midpoint) and the array of - signed offsets from that center, so each original pitch equals center + offset. + The center pitch: ``initial_pitch`` plus the midpoint of the offsets' range. """ + if not pitches: + return initial_pitch + differences = [pitch - initial_pitch for pitch in pitches] array = np.array(differences, dtype=np.int8) max_value = np.max(array) min_value = np.min(array) mean_value = (max_value + min_value) // 2 - return int(initial_pitch + mean_value), array - mean_value + return int(initial_pitch + mean_value) diff --git a/src/sampletones_core/exporters/lengths.py b/src/sampletones_core/exporters/lengths.py new file mode 100644 index 00000000..64f6144d --- /dev/null +++ b/src/sampletones_core/exporters/lengths.py @@ -0,0 +1,66 @@ +from collections.abc import Hashable +from typing import Dict, List, Optional, Tuple, TypeVar + +from sampletones_shared.logger import logger + +EnvelopeKey = TypeVar("EnvelopeKey", bound=Hashable) + + +def _resize(items: Tuple[int, ...], length: int) -> Tuple[int, ...]: + """Brings a sequence to a length, repeating its final value when it falls short.""" + return items[:length] + items[-1:] * (length - len(items)) + + +def _common_length(lengths: List[int], loop: bool, limit: Optional[int]) -> int: + """Chooses the length every populated dimension of an instrument shares. + + A looping instrument takes the shortest length, which drops the trailing note-off + volume item the loop would otherwise sound once per cycle; a one-shot takes the + longest, so each shorter dimension holds its final value to the end. A ``limit`` + caps the result, so an envelope longer than the target format stores keeps its + opening items and the rest is reported as dropped. + + Args: + lengths: The item counts of the populated dimensions. + loop: Whether the instrument loops while its note is held. + limit: The most items the target format stores, or ``None`` when it is unbounded. + + Returns: + int: The shared item count, at most ``limit`` where one applies. + """ + length = min(lengths) if loop else max(lengths) + if limit is None or length <= limit: + return length + + logger.warning(f"Instrument envelope of {length} items keeps its first {limit}, the most the format holds") + return limit + + +def equalize_lengths( + items_by_kind: Dict[EnvelopeKey, Tuple[int, ...]], + loop: bool, + *, + limit: Optional[int] = None, +) -> Dict[EnvelopeKey, Tuple[int, ...]]: + """Brings every populated dimension of an instrument to one common length. + + A tracker advances each dimension on its own per-tick counter, so dimensions of + unequal length pull apart: a looping instrument's envelopes slip by a tick per + cycle, and a one-shot's shorter dimensions expire while its volume still sounds. + + Args: + items_by_kind: The per-dimension item tuples, empty for a dimension the channel + leaves unused. + loop: Whether the instrument loops while its note is held. + limit: The most items the target format stores, or ``None`` when it is unbounded. + + Returns: + Dict[EnvelopeKey, Tuple[int, ...]]: The items with every populated dimension at + one length, leaving unused dimensions empty. + """ + lengths = [len(items) for items in items_by_kind.values() if items] + if not lengths: + return items_by_kind + + length = _common_length(lengths, loop, limit) + return {kind: _resize(items, length) if items else items for kind, items in items_by_kind.items()} diff --git a/src/sampletones_core/exporters/naming.py b/src/sampletones_core/exporters/naming.py new file mode 100644 index 00000000..a4871f6a --- /dev/null +++ b/src/sampletones_core/exporters/naming.py @@ -0,0 +1,19 @@ +from sampletones_core.constants.enums import GeneratorName + + +def instrument_slice_name(base_name: str, generator: GeneratorName) -> str: + """Names one generator slice of a reconstruction. + + Every export path shares this form, so a slice carries the same name whether it + reaches a tracker as a standalone instrument file or as one entry of a project's + instrument table. The parenthesised suffix keeps the base name readable while + identifying the channel the slice drives. + + Args: + base_name: The name of the reconstruction or sample the slice came from. + generator: The NES channel the slice covers. + + Returns: + str: The slice's name, of the form ``base (generator)``. + """ + return f"{base_name} ({generator})" diff --git a/src/sampletones_core/exporters/slices.py b/src/sampletones_core/exporters/slices.py new file mode 100644 index 00000000..6b87f51e --- /dev/null +++ b/src/sampletones_core/exporters/slices.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict, Iterator, Tuple + +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.exporters.feature import Features +from sampletones_core.exporters.naming import instrument_slice_name +from sampletones_core.project.instruments.sample import Sample +from sampletones_core.project.project import Project + + +@dataclass(frozen=True) +class InstrumentSlot: + """Where a sample's generator slice landed in the instrument table.""" + + index: int + initial_pitch: int + + +InstrumentTable = Dict[Tuple[str, GeneratorName], InstrumentSlot] + + +@dataclass(frozen=True) +class SampleSlice: + """One generator slice of a project sample, numbered for the instrument table. + + Attributes: + index: Position the slice takes in the exported instrument table. + sample: The sample whose reconstruction the slice came from. + generator: The NES channel the slice covers. + features: The per-dimension envelopes describing the slice. + """ + + index: int + sample: Sample + generator: GeneratorName + features: Features + + @property + def instrument_name(self) -> str: + """The exported instrument's name, naming both its sample and its channel.""" + return instrument_slice_name(self.sample.name, self.generator) + + @property + def key(self) -> Tuple[str, GeneratorName]: + """The identity a pattern row references the slice by.""" + return (self.sample.id, self.generator) + + @property + def slot(self) -> InstrumentSlot: + """The table position and reference pitch a pattern row resolves through.""" + return InstrumentSlot( + index=self.index, + initial_pitch=self.features.initial_pitch, + ) + + +def iterate_sample_slices(project: Project) -> Iterator[SampleSlice]: + """Walks every generator slice of every sample in instrument-table order. + + A sample contributes one slice per channel its reconstruction covers, so it yields + one to four. Slices are numbered in sample order, then channel order, which fixes + the instrument numbering every tracker format builds on. Each sample's features are + exported once, so a caller reads a reconstruction's envelopes at a single cost. + + Args: + project: The project whose samples are exported. + + Yields: + SampleSlice: Each slice alongside the index it takes in the instrument table. + """ + index = 0 + for sample in project.samples: + features_by_generator = sample.reconstruction.export() + for generator in GeneratorName.items(): + features = features_by_generator.get(generator) + if features is None: + continue + + yield SampleSlice( + index=index, + sample=sample, + generator=generator, + features=features, + ) + index += 1 diff --git a/src/sampletones_core/exporters/truncation.py b/src/sampletones_core/exporters/truncation.py new file mode 100644 index 00000000..726819e5 --- /dev/null +++ b/src/sampletones_core/exporters/truncation.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional, Sequence + + +@dataclass(frozen=True) +class EnvelopeTruncation: + """The frames a target format's item limit leaves out of the instruments one export wrote. + + Attributes: + frames: The frame count a shortened instrument carries. + source_frames: The longest envelope the export was given. + instruments: How many written instruments were shortened. + """ + + frames: int + source_frames: int + instruments: int + + @classmethod + def measure(cls, source_frames: int, limit: Optional[int]) -> Optional[EnvelopeTruncation]: + """Reports what an export of one instrument's envelopes keeps. + + Args: + source_frames: The frame count the envelopes arrived with. + limit: The most items the target format stores, or ``None`` when it is unbounded. + + Returns: + Optional[EnvelopeTruncation]: The shortening the limit imposes, and ``None`` + when the envelopes fit whole. + """ + if limit is None or source_frames <= limit: + return None + + return cls(frames=limit, source_frames=source_frames, instruments=1) + + @classmethod + def summarize( + cls, + truncations: Sequence[Optional[EnvelopeTruncation]], + ) -> Optional[EnvelopeTruncation]: + """Gathers the per-instrument shortenings of one export into a single report. + + Args: + truncations: One entry per written instrument, ``None`` where it fit whole. + + Returns: + Optional[EnvelopeTruncation]: The summary, and ``None`` when every instrument + carries its whole envelope. + """ + shortened = [truncation for truncation in truncations if truncation is not None] + if not shortened: + return None + + return cls( + frames=min(truncation.frames for truncation in shortened), + source_frames=max(truncation.source_frames for truncation in shortened), + instruments=sum(truncation.instruments for truncation in shortened), + ) diff --git a/src/sampletones_core/famitracker/sequences/lengths.py b/src/sampletones_core/famitracker/sequences/lengths.py deleted file mode 100644 index 5c96bd88..00000000 --- a/src/sampletones_core/famitracker/sequences/lengths.py +++ /dev/null @@ -1,65 +0,0 @@ -from typing import Dict, List, Tuple - -from sampletones_core.famitracker.specification.sequences import ( - MAX_SEQUENCE_ITEMS, - SequenceKind, -) -from sampletones_shared.logger import logger - - -def _resize(items: Tuple[int, ...], length: int) -> Tuple[int, ...]: - """Brings a sequence to a length, repeating its final value when it falls short.""" - return items[:length] + items[-1:] * (length - len(items)) - - -def _common_length(lengths: List[int], loop: bool) -> int: - """Chooses the length every populated sequence of an instrument shares. - - A looping instrument takes the shortest length, which drops the trailing note-off - volume item the loop would otherwise sound once per cycle; a one-shot takes the - longest, so each shorter dimension holds its final value to the end. The result - stays within the item count FamiTracker stores, so an envelope longer than that - keeps its opening items and the rest is reported as dropped. - - Args: - lengths: The item counts of the populated dimensions. - loop: Whether the instrument loops while its note is held. - - Returns: - int: The shared item count, at most ``MAX_SEQUENCE_ITEMS``. - """ - length = min(lengths) if loop else max(lengths) - if length <= MAX_SEQUENCE_ITEMS: - return length - - logger.warning( - f"Instrument envelope of {length} items keeps its first {MAX_SEQUENCE_ITEMS}, " - f"the most FamiTracker stores in a sequence" - ) - return MAX_SEQUENCE_ITEMS - - -def equalize_lengths( - items_by_kind: Dict[SequenceKind, Tuple[int, ...]], - loop: bool, -) -> Dict[SequenceKind, Tuple[int, ...]]: - """Brings every populated sequence of an instrument to one common length. - - FamiTracker advances each sequence on its own per-tick counter, so dimensions of - unequal length pull apart: a looping instrument's envelopes slip by a tick per - cycle, and a one-shot's shorter dimensions expire while its volume still sounds. - - Args: - items_by_kind: The per-kind item tuples, empty for a disabled dimension. - loop: Whether the instrument loops while its note is held. - - Returns: - Dict[SequenceKind, Tuple[int, ...]]: The items with every populated kind at - one length, leaving disabled kinds empty. - """ - lengths = [len(items) for items in items_by_kind.values() if items] - if not lengths: - return items_by_kind - - length = _common_length(lengths, loop) - return {kind: _resize(items, length) if items else items for kind, items in items_by_kind.items()} diff --git a/src/sampletones_core/famitracker/sequences/truncation.py b/src/sampletones_core/famitracker/sequences/truncation.py deleted file mode 100644 index 72c4bf50..00000000 --- a/src/sampletones_core/famitracker/sequences/truncation.py +++ /dev/null @@ -1,35 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from typing import Optional - -from sampletones_core.famitracker.specification.sequences import MAX_SEQUENCE_ITEMS - - -@dataclass(frozen=True) -class SequenceTruncation: - """The frames of an envelope the FamiTracker sequence limit leaves out. - - Attributes: - frames: The frame count the exported sequences carry. - source_frames: The frame count the envelopes arrived with. - """ - - frames: int - source_frames: int - - @classmethod - def measure(cls, source_frames: int) -> Optional[SequenceTruncation]: - """Reports what an export of this many frames keeps. - - Args: - source_frames: The frame count the envelopes arrived with. - - Returns: - Optional[SequenceTruncation]: The shortening the limit imposes, and ``None`` - when the envelopes fit whole. - """ - if source_frames <= MAX_SEQUENCE_ITEMS: - return None - - return cls(frames=MAX_SEQUENCE_ITEMS, source_frames=source_frames) diff --git a/src/sampletones_core/famitracker/specification/__init__.py b/src/sampletones_core/formats/__init__.py similarity index 100% rename from src/sampletones_core/famitracker/specification/__init__.py rename to src/sampletones_core/formats/__init__.py diff --git a/tests/unit/sampletones_core/famitracker/__init__.py b/src/sampletones_core/formats/bitphase/__init__.py similarity index 100% rename from tests/unit/sampletones_core/famitracker/__init__.py rename to src/sampletones_core/formats/bitphase/__init__.py diff --git a/src/sampletones_core/formats/bitphase/btp.py b/src/sampletones_core/formats/bitphase/btp.py new file mode 100644 index 00000000..3da5e73b --- /dev/null +++ b/src/sampletones_core/formats/bitphase/btp.py @@ -0,0 +1,42 @@ +import gzip +import json +from pathlib import Path +from typing import Final, Tuple + +from sampletones_core.formats.bitphase.model.project import BitphaseProject + +JSON_SEPARATORS: Final[Tuple[str, str]] = (",", ":") +FIXED_TIMESTAMP: Final[int] = 0 + + +def project_to_bytes(project: BitphaseProject) -> bytes: + """Serializes a document the way Bitphase reads it back. + + A ``.btp`` is the document's JSON under gzip, written without separator padding + and with a fixed timestamp, so exporting the same document twice yields the same + bytes. + + Args: + project: The document to serialize. + + Returns: + bytes: The file's contents. + """ + payload = json.dumps( + project.model_dump(mode="json", by_alias=True), + separators=JSON_SEPARATORS, + ) + return gzip.compress(payload.encode("utf-8"), mtime=FIXED_TIMESTAMP) + + +def write_btp(destination: Path, project: BitphaseProject) -> None: + """Writes a Bitphase document to disk. + + Args: + destination: The file to write. + project: The document to serialize. + + Raises: + OSError: If the destination cannot be written. + """ + destination.write_bytes(project_to_bytes(project)) diff --git a/src/sampletones_core/formats/bitphase/builder.py b/src/sampletones_core/formats/bitphase/builder.py new file mode 100644 index 00000000..50f0ed95 --- /dev/null +++ b/src/sampletones_core/formats/bitphase/builder.py @@ -0,0 +1,429 @@ +import math +from dataclasses import dataclass +from typing import Dict, List, Sequence, Tuple + +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.exporters.slices import iterate_sample_slices +from sampletones_core.formats.bitphase.envelopes import ChannelEnvelopes, features_to_envelopes +from sampletones_core.formats.bitphase.identifiers import format_instrument_id +from sampletones_core.formats.bitphase.model.instrument import BitphaseInstrument +from sampletones_core.formats.bitphase.model.pattern import ( + BitphaseChannel, + BitphasePattern, + BitphaseRow, + NoteCell, +) +from sampletones_core.formats.bitphase.model.project import BitphaseProject +from sampletones_core.formats.bitphase.model.song import BitphaseSong +from sampletones_core.formats.bitphase.model.table import BitphaseTable +from sampletones_core.formats.bitphase.notes import ( + noise_period_to_note_index, + note_index_to_note_cell, + pitch_to_note_index, +) +from sampletones_core.formats.bitphase.specification.channels import CHANNEL_LABELS, GENERATOR_NAME_TO_CHANNEL_INDEX +from sampletones_core.formats.bitphase.specification.chip import ( + CPU_FREQUENCIES, + DEFAULT_A4_TUNING, + DEFAULT_CHIP_VARIANT, +) +from sampletones_core.formats.bitphase.specification.instruments import ( + MAX_INSTRUMENT_ID, + MAX_TABLE_ID, + MIN_INSTRUMENT_ID, + MIN_TABLE_ID, +) +from sampletones_core.formats.bitphase.specification.patterns import ( + FIRST_PATTERN_ID, + FULL_VOLUME, + MAX_PATTERN_LENGTH, + MIN_PATTERN_LENGTH, + NO_VOLUME_CHANGE, + TABLE_COLUMN_OFFSET, + NoteName, +) +from sampletones_core.formats.bitphase.tuning import generate_tuning_table +from sampletones_core.project.instruments.instrument import Instrument +from sampletones_core.project.instruments.note_off import NoteOff +from sampletones_core.project.patterns.row import Row +from sampletones_core.project.project import Project +from sampletones_core.trackers.request import InstrumentExport, SampleExport +from sampletones_shared.constants.project import DEFAULT_ROWS_PER_PATTERN, DEFAULT_SPEED + +PREVIEW_SPEED = DEFAULT_SPEED +PREVIEW_TRIGGER_ROW = 0 +PREVIEW_REST_PATTERN_ID = FIRST_PATTERN_ID + 1 +NO_AUTHOR = "" + + +@dataclass(frozen=True) +class Voice: + """One built instrument together with the table and the note that triggers it. + + Attributes: + number: Value a pattern's instrument column carries to play the instrument. + instrument: The per-tick rows the channel takes on. + table: The per-tick semitone contour that moves the note. + generator: The NES channel the slice was reconstructed for. + initial_pitch: Pitch the slice's contour is measured against. + ticks: How many ticks the instrument runs before it loops. + """ + + number: int + instrument: BitphaseInstrument + table: BitphaseTable + generator: GeneratorName + initial_pitch: int + ticks: int + + +VoiceTable = Dict[Tuple[str, GeneratorName], Voice] + + +def _build_voice( + index: int, + name: str, + generator: GeneratorName, + initial_pitch: int, + envelopes: ChannelEnvelopes, +) -> Voice: + """Numbers one generator slice and packages it as an instrument-and-table pair. + + Instruments and tables are numbered alike, so a pattern cell names the same position + in both columns. + + Raises: + ValueError: If the position runs past what a pattern column can name. + """ + number = index + MIN_INSTRUMENT_ID + if number > MAX_INSTRUMENT_ID: + raise ValueError(f"Document exceeds the Bitphase limit of {MAX_INSTRUMENT_ID} instruments") + + table_id = index + MIN_TABLE_ID + if table_id > MAX_TABLE_ID: + raise ValueError(f"Document exceeds the Bitphase limit of {MAX_TABLE_ID + 1} tables") + + return Voice( + number=number, + instrument=BitphaseInstrument( + id=format_instrument_id(number), + rows=envelopes.rows, + loop=envelopes.loop, + name=name, + ), + table=BitphaseTable( + id=table_id, + rows=envelopes.table_rows, + loop=envelopes.loop, + name=name, + ), + generator=generator, + initial_pitch=initial_pitch, + ticks=len(envelopes.rows), + ) + + +def _note_cell(channel_generator: GeneratorName, pitch: int) -> NoteCell: + """Resolves a pitch to the note column of the channel the row sits on. + + The noise channel reads its note as a period selector, so its pitch takes the + mapping that reproduces that period; every other channel reads the tuning table. + """ + if channel_generator == GeneratorName.NOISE: + return note_index_to_note_cell(noise_period_to_note_index(pitch)) + + return note_index_to_note_cell(pitch_to_note_index(pitch)) + + +def _trigger_row(voice: Voice, note: NoteCell, volume: int) -> BitphaseRow: + return BitphaseRow( + note=note, + instrument=voice.number, + table=voice.table.id + TABLE_COLUMN_OFFSET, + volume=volume, + ) + + +def _empty_channels(length: int) -> List[List[BitphaseRow]]: + return [[BitphaseRow() for _ in range(length)] for _ in CHANNEL_LABELS] + + +def _to_pattern( + pattern_id: int, + length: int, + channel_rows: Sequence[Sequence[BitphaseRow]], +) -> BitphasePattern: + channels = tuple( + BitphaseChannel(rows=tuple(rows), label=label) + for label, rows in zip( + CHANNEL_LABELS, + channel_rows, + ) + ) + return BitphasePattern(id=pattern_id, length=length, channels=channels) + + +def _build_song( + patterns: Tuple[BitphasePattern, ...], + *, + speed: int, + nes_frequency: int, +) -> BitphaseSong: + chip_frequency = CPU_FREQUENCIES[DEFAULT_CHIP_VARIANT] + return BitphaseSong( + patterns=patterns, + tuning_table=generate_tuning_table( + chip_frequency, + a4_tuning=DEFAULT_A4_TUNING, + ), + initial_speed=speed, + chip_frequency=chip_frequency, + interrupt_frequency=nes_frequency, + ) + + +def _preview_length(voices: Sequence[Voice]) -> int: + """Sizes the preview pattern so a full line of it covers the longest instrument.""" + rows = math.ceil(max((voice.ticks for voice in voices), default=0) / PREVIEW_SPEED) + return max( + MIN_PATTERN_LENGTH, + min(MAX_PATTERN_LENGTH, max(rows, DEFAULT_ROWS_PER_PATTERN)), + ) + + +def _preview_order(voices: Sequence[Voice], length: int) -> Tuple[int, ...]: + """Spaces the trigger far enough apart for the longest instrument to play through. + + Every order position past the first plays a resting pattern, so an instrument that + outlasts a single pattern still reaches its end before the trigger comes round again. + """ + ticks = max((voice.ticks for voice in voices), default=0) + positions = max(1, math.ceil(ticks / (length * PREVIEW_SPEED))) + return (FIRST_PATTERN_ID,) + (PREVIEW_REST_PATTERN_ID,) * (positions - 1) + + +def _preview_patterns( + voices: Sequence[Voice], + length: int, + positions: int, +) -> Tuple[BitphasePattern, ...]: + channel_rows = _empty_channels(length) + for voice in voices: + channel = GENERATOR_NAME_TO_CHANNEL_INDEX[voice.generator] + note = _note_cell(voice.generator, voice.initial_pitch) + channel_rows[channel][PREVIEW_TRIGGER_ROW] = _trigger_row( + voice, + note, + FULL_VOLUME, + ) + + patterns = [_to_pattern(FIRST_PATTERN_ID, length, channel_rows)] + if positions > 1: + patterns.append( + _to_pattern(PREVIEW_REST_PATTERN_ID, length, _empty_channels(length)), + ) + + return tuple(patterns) + + +def sample_to_bitphase(request: SampleExport) -> BitphaseProject: + """Builds a playable Bitphase document holding one reconstruction's instruments. + + Every generator slice becomes an instrument and the table that carries its pitch + contour, and one pattern triggers each slice on the channel it was reconstructed + for, so opening the document and pressing play sounds the reconstruction. + + Args: + request: The reconstruction's slices. + + Returns: + BitphaseProject: The document to serialize. + + Raises: + ValueError: If the reconstruction holds more slices than Bitphase has room for. + """ + voices = [ + _build_voice( + index, + instrument.name, + instrument.generator, + instrument.features.initial_pitch, + features_to_envelopes( + instrument.features, + instrument.generator, + loop=instrument.loop, + ), + ) + for index, instrument in enumerate(request.instruments) + ] + + length = _preview_length(voices) + order = _preview_order(voices, length) + patterns = _preview_patterns(voices, length, len(order)) + + return BitphaseProject( + name=request.name, + author=NO_AUTHOR, + songs=(_build_song(patterns, speed=PREVIEW_SPEED, nes_frequency=request.nes_frequency),), + pattern_order=order, + tables=tuple(voice.table for voice in voices), + instruments=tuple(voice.instrument for voice in voices), + ) + + +def instrument_to_bitphase(request: InstrumentExport) -> BitphaseProject: + """Builds a playable Bitphase document holding one generator slice. + + Args: + request: The slice to write. + + Returns: + BitphaseProject: The document to serialize. + """ + sample = SampleExport( + name=request.name, + instruments=(request,), + nes_frequency=request.nes_frequency, + ) + return sample_to_bitphase(sample) + + +def _build_voice_table(project: Project) -> Tuple[List[Voice], VoiceTable]: + voices: List[Voice] = [] + by_reference: VoiceTable = {} + + for sample_slice in iterate_sample_slices(project): + envelopes = features_to_envelopes( + sample_slice.features, + sample_slice.generator, + loop=sample_slice.sample.loop, + ) + voice = _build_voice( + sample_slice.index, + sample_slice.instrument_name, + sample_slice.generator, + sample_slice.features.initial_pitch, + envelopes, + ) + voices.append(voice) + by_reference[sample_slice.key] = voice + + return voices, by_reference + + +def _resolve_voice(reference: Instrument, voices: VoiceTable) -> Voice: + voice = voices.get((reference.sample_id, reference.generator_name)) + if voice is None: + raise ValueError( + f"Row references sample '{reference.sample_id}' slice " + f"'{reference.generator_name}' that has no instrument" + ) + + return voice + + +def _row_cell( + row: Row, + channel_generator: GeneratorName, + voices: VoiceTable, +) -> BitphaseRow: + """Converts one tracker line to the Bitphase row that plays it. + + Raises: + ValueError: If the line references a sample slice that has no instrument. + """ + volume = row.volume if row.volume is not None else NO_VOLUME_CHANGE + cell = BitphaseRow(volume=volume) + + match row.command: + case NoteOff(): + cell = BitphaseRow( + note=NoteCell(name=int(NoteName.OFF)), + volume=volume, + ) + case Instrument() as reference: + voice = _resolve_voice(reference, voices) + pitch = voice.initial_pitch + (row.transpose or 0) + cell = _trigger_row( + voice, + _note_cell(channel_generator, pitch), + volume, + ) + case None: + pass + + return cell + + +def _channel_rows( + rows: Sequence[Row], + length: int, + generator: GeneratorName, + voices: VoiceTable, +) -> List[BitphaseRow]: + cells = [_row_cell(row, generator, voices) for row in rows[:length]] + cells.extend(BitphaseRow() for _ in range(length - len(cells))) + return cells + + +def _project_patterns(project: Project, voices: VoiceTable) -> Tuple[BitphasePattern, ...]: + """Flattens the song's per-channel arrangement into whole-pattern order positions. + + A SampleToNES order frame points every channel at its own pattern, where a Bitphase + order position names one pattern that spans all channels, so each frame becomes a + pattern of its own carrying that frame's channels side by side. + """ + song = project.song + length = song.rows_per_pattern + patterns: List[BitphasePattern] = [] + + for position, frame in enumerate(song.order): + channel_rows = _empty_channels(length) + for generator in GeneratorName.items(): + index = frame.get(generator) + if index is None: + continue + + pattern = song.channels[generator].pattern(index) + if pattern is None: + continue + + channel = GENERATOR_NAME_TO_CHANNEL_INDEX[generator] + channel_rows[channel] = _channel_rows( + pattern.rows, + length, + generator, + voices, + ) + + patterns.append(_to_pattern(position, length, channel_rows)) + + return tuple(patterns) + + +def project_to_bitphase(project: Project) -> BitphaseProject: + """Maps a project's samples and song onto the Bitphase document IR. + + Args: + project: The project to write. + + Returns: + BitphaseProject: The document to serialize. + + Raises: + ValueError: If the project holds more than Bitphase has room for, or a row + references a sample slice that has no instrument. + """ + voices, by_reference = _build_voice_table(project) + patterns = _project_patterns(project, by_reference) + settings = project.settings + info = project.info + + return BitphaseProject( + name=info.title, + author=info.author, + songs=(_build_song(patterns, speed=settings.speed, nes_frequency=settings.nes_frequency),), + pattern_order=tuple(pattern.id for pattern in patterns), + tables=tuple(voice.table for voice in voices), + instruments=tuple(voice.instrument for voice in voices), + ) diff --git a/src/sampletones_core/formats/bitphase/envelopes.py b/src/sampletones_core/formats/bitphase/envelopes.py new file mode 100644 index 00000000..917e4f3e --- /dev/null +++ b/src/sampletones_core/formats/bitphase/envelopes.py @@ -0,0 +1,127 @@ +from dataclasses import dataclass +from typing import Dict, Final, Optional, Tuple + +import numpy as np + +from sampletones_core.constants.enums import FeatureKey, GeneratorName +from sampletones_core.exporters.feature import Features +from sampletones_core.exporters.lengths import equalize_lengths +from sampletones_core.formats.bitphase.model.instrument import NesInstrumentRow +from sampletones_core.formats.bitphase.notes import noise_arpeggio_to_table_offset +from sampletones_core.formats.bitphase.specification.instruments import ( + FLAT_PULSE_WIDTH, + LOOP_FROM_START, + NO_TABLE_OFFSET, + NOISE_MODE_LONG, + NOISE_MODE_SHORT, + SILENT_VOLUME, +) + +SILENT_ROW: Final[NesInstrumentRow] = NesInstrumentRow( + pulse_width=FLAT_PULSE_WIDTH, + volume_or_rate=SILENT_VOLUME, +) + + +@dataclass(frozen=True) +class ChannelEnvelopes: + """One generator slice expressed the way Bitphase plays it back. + + The instrument rows and the table rows advance on their own per-tick counters, so + they share a length and a loop point and stay in step for as long as the note + sounds. + + Attributes: + rows: Instrument rows, one per engine tick. + table_rows: Semitone offsets, one per engine tick. + loop: Row both lists return to once they run off the end. + """ + + rows: Tuple[NesInstrumentRow, ...] + table_rows: Tuple[int, ...] + loop: int + + +def _to_items(array: Optional[np.ndarray]) -> Tuple[int, ...]: + if array is None: + return () + return tuple(int(value) for value in array) + + +def _pulse_width(generator: GeneratorName, duty_cycle: int) -> int: + """Reads a duty-cycle item as the field the channel uses it for. + + A square channel takes it as the duty itself; the noise channel takes any nonzero + value as its short LFSR mode; the triangle channel plays one fixed waveform. + """ + match generator: + case GeneratorName.PULSE1 | GeneratorName.PULSE2: + return duty_cycle + case GeneratorName.NOISE: + return NOISE_MODE_SHORT if duty_cycle else NOISE_MODE_LONG + case GeneratorName.TRIANGLE: + return FLAT_PULSE_WIDTH + + +def _table_offset(generator: GeneratorName, arpeggio: int) -> int: + if generator == GeneratorName.NOISE: + return noise_arpeggio_to_table_offset(arpeggio) + + return arpeggio + + +def features_to_envelopes( + features: Features, + generator: GeneratorName, + *, + loop: bool, +) -> ChannelEnvelopes: + """Converts one generator slice's envelopes into Bitphase instrument and table rows. + + Volume becomes the instrument's per-tick level, the duty cycle becomes the channel's + waveform field, and the arpeggio becomes the table contour that moves the note. A + looping slice returns to its first row so it sustains for as long as the note is + held; a one-shot returns to its last row, which the volume envelope already leaves + silent, so it rests there once it has played through. + + Args: + features: The per-dimension envelopes describing the slice. + generator: The NES channel the slice was reconstructed for. + loop: Whether the instrument repeats its envelopes while its note is held. + + Returns: + ChannelEnvelopes: The rows, contour, and loop point describing the slice. + """ + arrays: Dict[FeatureKey, Optional[np.ndarray]] = { + FeatureKey.VOLUME: features.volume, + FeatureKey.ARPEGGIO: features.arpeggio, + FeatureKey.DUTY_CYCLE: features.duty_cycle, + } + items = equalize_lengths({key: _to_items(array) for key, array in arrays.items()}, loop) + + volumes = items[FeatureKey.VOLUME] + arpeggios = items[FeatureKey.ARPEGGIO] + duty_cycles = items[FeatureKey.DUTY_CYCLE] + + if not volumes: + return ChannelEnvelopes( + rows=(SILENT_ROW,), + table_rows=(NO_TABLE_OFFSET,), + loop=LOOP_FROM_START, + ) + + rows = tuple( + NesInstrumentRow( + pulse_width=_pulse_width(generator, duty_cycles[frame] if duty_cycles else FLAT_PULSE_WIDTH), + volume_or_rate=volume, + ) + for frame, volume in enumerate(volumes) + ) + contour = arpeggios or (NO_TABLE_OFFSET,) * len(volumes) + table_rows = tuple(_table_offset(generator, arpeggio) for arpeggio in contour) + + return ChannelEnvelopes( + rows=rows, + table_rows=table_rows, + loop=LOOP_FROM_START if loop else len(rows) - 1, + ) diff --git a/src/sampletones_core/formats/bitphase/identifiers.py b/src/sampletones_core/formats/bitphase/identifiers.py new file mode 100644 index 00000000..1bd92df9 --- /dev/null +++ b/src/sampletones_core/formats/bitphase/identifiers.py @@ -0,0 +1,20 @@ +from sampletones_core.formats.bitphase.specification.instruments import INSTRUMENT_ID_DIGITS +from sampletones_core.formats.bitphase.specification.patterns import SYMBOL_BASE, SYMBOL_DIGITS + + +def format_instrument_id(number: int) -> str: + """Renders an instrument number as the base-36 text a pattern column matches on. + + Args: + number: Instrument number, at most :data:`MAX_INSTRUMENT_ID`. + + Returns: + str: The number in base 36, padded to the width of the instrument column. + """ + digits = "" + remaining = number + for _ in range(INSTRUMENT_ID_DIGITS): + remaining, digit = divmod(remaining, SYMBOL_BASE) + digits = SYMBOL_DIGITS[digit] + digits + + return digits diff --git a/tests/unit/sampletones_core/famitracker/model/__init__.py b/src/sampletones_core/formats/bitphase/model/__init__.py similarity index 100% rename from tests/unit/sampletones_core/famitracker/model/__init__.py rename to src/sampletones_core/formats/bitphase/model/__init__.py diff --git a/src/sampletones_core/formats/bitphase/model/config.py b/src/sampletones_core/formats/bitphase/model/config.py new file mode 100644 index 00000000..8546c1ec --- /dev/null +++ b/src/sampletones_core/formats/bitphase/model/config.py @@ -0,0 +1,10 @@ +from typing import Final + +from pydantic import ConfigDict +from pydantic.alias_generators import to_camel + +BITPHASE_MODEL_CONFIG: Final[ConfigDict] = ConfigDict( + alias_generator=to_camel, + populate_by_name=True, + frozen=True, +) diff --git a/src/sampletones_core/formats/bitphase/model/instrument.py b/src/sampletones_core/formats/bitphase/model/instrument.py new file mode 100644 index 00000000..37cb71af --- /dev/null +++ b/src/sampletones_core/formats/bitphase/model/instrument.py @@ -0,0 +1,154 @@ +from typing import Tuple + +from pydantic import BaseModel, Field + +from sampletones_core.formats.bitphase.model.config import BITPHASE_MODEL_CONFIG +from sampletones_core.formats.bitphase.specification.chip import CHIP_TYPE_NES +from sampletones_core.formats.bitphase.specification.instruments import ( + ABSOLUTE_TONE, + CONSTANT_VOLUME, + KEEP_PHASE, + LOOP_FROM_START, + MAX_PULSE_WIDTH, + MAX_SOUND_LENGTH, + MAX_SWEEP_RATE, + MAX_SWEEP_SHIFT, + MAX_TONE_ADD, + MAX_VOLUME_OR_RATE, + MIN_PULSE_WIDTH, + MIN_SOUND_LENGTH, + MIN_SWEEP_RATE, + MIN_SWEEP_SHIFT, + MIN_TONE_ADD, + MIN_VOLUME_OR_RATE, + NO_SWEEP, + NO_SWEEP_RATE, + NO_SWEEP_SHIFT, + NO_TONE_OFFSET, + SUSTAINED_SOUND_LENGTH, +) + + +class NesInstrumentRow(BaseModel): + """One tick of a Bitphase NES instrument. + + An instrument advances one row per engine tick, so a row carries every register + value the channel takes for that tick. ``pulse_width`` selects the duty on a square + channel and the LFSR mode on the noise channel; ``volume_or_rate`` is a literal + volume while ``envelope`` stays off. The remaining fields hold the settings a + reconstruction leaves alone: the note sustains, the pitch comes from the tuning + table, and the hardware sweep stays disabled. + """ + + model_config = BITPHASE_MODEL_CONFIG + + pulse_width: int = Field( + ..., + ge=MIN_PULSE_WIDTH, + le=MAX_PULSE_WIDTH, + description="Square duty cycle, or the noise channel's LFSR mode.", + ) + volume_or_rate: int = Field( + ..., + ge=MIN_VOLUME_OR_RATE, + le=MAX_VOLUME_OR_RATE, + description="Channel volume while the hardware envelope stays off.", + ) + retrigger: bool = Field( + default=KEEP_PHASE, + description="Restarts the waveform phase this tick.", + ) + sound_length: int = Field( + default=SUSTAINED_SOUND_LENGTH, + ge=MIN_SOUND_LENGTH, + le=MAX_SOUND_LENGTH, + description="Length counter in ticks; zero holds the note for as long as the envelope runs.", + ) + envelope: bool = Field( + default=CONSTANT_VOLUME, + description="Reads volume_or_rate as a decay rate.", + ) + tone_add: int = Field( + default=NO_TONE_OFFSET, + ge=MIN_TONE_ADD, + le=MAX_TONE_ADD, + description="Offset added to the tuning-table period on a square or triangle channel.", + ) + tone_accumulation: bool = Field( + default=ABSOLUTE_TONE, + description="Sums tone_add across ticks.", + ) + sweep: bool = Field( + default=NO_SWEEP, + description="Enables the square channel's sweep unit.", + ) + sweep_rate: int = Field( + default=NO_SWEEP_RATE, + ge=MIN_SWEEP_RATE, + le=MAX_SWEEP_RATE, + ) + sweep_shift: int = Field( + default=NO_SWEEP_SHIFT, + ge=MIN_SWEEP_SHIFT, + le=MAX_SWEEP_SHIFT, + ) + + +class BitphaseInstrument(BaseModel): + """A named row list one pattern cell triggers, held in a project's instrument list. + + ``id`` is the base-36 text a pattern's instrument column matches on, and ``loop`` + is the row playback returns to once it runs off the end. + """ + + model_config = BITPHASE_MODEL_CONFIG + + id: str = Field( + ..., + description="Base-36 identifier a pattern row references.", + ) + chip_type: str = Field( + default=CHIP_TYPE_NES, + description="Chip whose row layout the instrument uses.", + ) + rows: Tuple[NesInstrumentRow, ...] = Field( + ..., + description="One row per engine tick.", + ) + loop: int = Field( + default=LOOP_FROM_START, + ge=0, + description="Row playback returns to after the last row.", + ) + name: str = Field( + ..., + description="Name shown in the instrument list.", + ) + + +class BitphaseInstrumentPreset(BaseModel): + """A single instrument as Bitphase's instruments panel loads and saves it. + + The panel writes the loaded rows into the instrument slot the user has selected, + which supplies the id and leaves this file carrying the rows alone. + """ + + model_config = BITPHASE_MODEL_CONFIG + + chip_type: str = Field( + default=CHIP_TYPE_NES, + description="Chip whose row layout the preset uses.", + ) + name: str = Field( + ..., + description="Name the preset offers for the instrument.", + ) + loop: int = Field( + default=LOOP_FROM_START, + ge=0, + description="Row playback returns to after the last row.", + ) + rows: Tuple[NesInstrumentRow, ...] = Field( + ..., + description="One row per engine tick.", + ) diff --git a/src/sampletones_core/formats/bitphase/model/pattern.py b/src/sampletones_core/formats/bitphase/model/pattern.py new file mode 100644 index 00000000..b2514f15 --- /dev/null +++ b/src/sampletones_core/formats/bitphase/model/pattern.py @@ -0,0 +1,110 @@ +from typing import Dict, Optional, Tuple + +from pydantic import BaseModel, Field + +from sampletones_core.formats.bitphase.model.config import BITPHASE_MODEL_CONFIG +from sampletones_core.formats.bitphase.specification.patterns import ( + EMPTY_OCTAVE, + FULL_VOLUME, + MAX_PATTERN_LENGTH, + MIN_PATTERN_LENGTH, + NO_INSTRUMENT_CHANGE, + NO_TABLE_CHANGE, + NO_VOLUME_CHANGE, + NoteName, +) + + +class NoteCell(BaseModel): + """The note column of one pattern row, naming a semitone and its octave.""" + + model_config = BITPHASE_MODEL_CONFIG + + name: int = Field( + default=int(NoteName.NONE), + ge=int(NoteName.NONE), + le=int(NoteName.B), + description="Semitone within the octave, or a non-pitched marker.", + ) + octave: int = Field( + default=EMPTY_OCTAVE, + ge=EMPTY_OCTAVE, + description="Octave the semitone sounds in.", + ) + + +class EffectCell(BaseModel): + """One effect column of a pattern row.""" + + model_config = BITPHASE_MODEL_CONFIG + + effect: int = Field(..., description="Effect identifier.") + delay: int = Field(default=0, description="Ticks the effect waits before it applies.") + parameter: int = Field(default=0, description="Effect argument.") + table_index: Optional[int] = Field( + default=None, + description="Table the effect drives, where it takes one.", + ) + + +class BitphaseRow(BaseModel): + """A single tracker line on one channel. + + Every column beyond the note carries its own "leave as it is" value, so a blank + line keeps whatever the channel already plays. + """ + + model_config = BITPHASE_MODEL_CONFIG + + note: NoteCell = Field(default_factory=NoteCell, description="Note column.") + effects: Tuple[Optional[EffectCell], ...] = Field( + default=(None,), + description="One entry per effect column.", + ) + instrument: int = Field( + default=NO_INSTRUMENT_CHANGE, + ge=NO_INSTRUMENT_CHANGE, + description="Instrument to play from this line on.", + ) + table: int = Field(default=NO_TABLE_CHANGE, description="Table to attach from this line on.") + volume: int = Field( + default=NO_VOLUME_CHANGE, + ge=NO_VOLUME_CHANGE, + le=FULL_VOLUME, + description="Channel volume from this line on.", + ) + + +class BitphaseChannel(BaseModel): + """One channel's lines within a pattern.""" + + model_config = BITPHASE_MODEL_CONFIG + + rows: Tuple[BitphaseRow, ...] = Field(..., description="One row per pattern line.") + label: str = Field(..., description="Name of the channel the lines drive.") + + +class BitphasePattern(BaseModel): + """One block of tracker lines across every channel. + + ``pattern_rows`` holds the columns a chip declares song-wide rather than per + channel; the 2A03 declares none, so Bitphase fills the block itself. + """ + + model_config = BITPHASE_MODEL_CONFIG + + id: int = Field(..., ge=0, description="Identifier the pattern order references.") + length: int = Field( + ..., + ge=MIN_PATTERN_LENGTH, + le=MAX_PATTERN_LENGTH, + description="Line count every channel of the pattern shares.", + ) + channels: Tuple[BitphaseChannel, ...] = Field( + ..., + description="One entry per chip channel.", + ) + pattern_rows: Tuple[Dict[str, int], ...] = Field( + default=(), + description="Song-wide columns, one entry per line.", + ) diff --git a/src/sampletones_core/formats/bitphase/model/project.py b/src/sampletones_core/formats/bitphase/model/project.py new file mode 100644 index 00000000..27f1b72a --- /dev/null +++ b/src/sampletones_core/formats/bitphase/model/project.py @@ -0,0 +1,54 @@ +from typing import Dict, Tuple + +from pydantic import BaseModel, Field + +from sampletones_core.formats.bitphase.model.config import BITPHASE_MODEL_CONFIG +from sampletones_core.formats.bitphase.model.instrument import BitphaseInstrument +from sampletones_core.formats.bitphase.model.song import BitphaseSong +from sampletones_core.formats.bitphase.model.table import BitphaseTable +from sampletones_core.formats.bitphase.specification.patterns import FIRST_PATTERN_ID + + +class BitphaseProject(BaseModel): + """Everything one Bitphase document holds. + + Instruments and tables are owned by the project rather than by a song, so every + song addresses the same instrument list. ``pattern_order`` names the pattern each + order position plays, and ``loop_point_id`` is the position playback returns to. + """ + + model_config = BITPHASE_MODEL_CONFIG + + name: str = Field( + ..., + description="Title shown for the document.", + ) + author: str = Field( + ..., + description="Author credited for the document.", + ) + songs: Tuple[BitphaseSong, ...] = Field( + ..., + description="Every song the document holds.", + ) + loop_point_id: int = Field( + default=FIRST_PATTERN_ID, + ge=0, + description="Order position playback returns to at the end.", + ) + pattern_order: Tuple[int, ...] = Field( + ..., + description="Pattern id played at each order position.", + ) + tables: Tuple[BitphaseTable, ...] = Field( + ..., + description="Every semitone contour the patterns attach.", + ) + pattern_order_colors: Dict[int, str] = Field( + default_factory=dict, + description="Highlight colour per order position.", + ) + instruments: Tuple[BitphaseInstrument, ...] = Field( + ..., + description="Every instrument the patterns trigger.", + ) diff --git a/src/sampletones_core/formats/bitphase/model/song.py b/src/sampletones_core/formats/bitphase/model/song.py new file mode 100644 index 00000000..3c852e3f --- /dev/null +++ b/src/sampletones_core/formats/bitphase/model/song.py @@ -0,0 +1,64 @@ +from typing import Dict, Tuple + +from pydantic import BaseModel, Field + +from sampletones_core.formats.bitphase.model.config import BITPHASE_MODEL_CONFIG +from sampletones_core.formats.bitphase.model.pattern import BitphasePattern +from sampletones_core.formats.bitphase.specification.chip import ( + CHIP_TYPE_NES, + DEFAULT_A4_TUNING, + DEFAULT_CHIP_VARIANT, + MAX_INITIAL_SPEED, + MIN_INITIAL_SPEED, + ChipVariant, +) + + +class BitphaseSong(BaseModel): + """One arrangement of patterns, along with the chip settings it plays under. + + ``interrupt_frequency`` is the engine tick rate in Hz, so it carries the rate a + reconstruction's envelopes were measured at; ``initial_speed`` is how many of those + ticks each pattern line lasts. + """ + + model_config = BITPHASE_MODEL_CONFIG + + patterns: Tuple[BitphasePattern, ...] = Field( + ..., + description="Every pattern the song holds.", + ) + tuning_table: Tuple[int, ...] = Field( + ..., + description="Channel period for each of the 96 note indices.", + ) + initial_speed: int = Field( + ..., + ge=MIN_INITIAL_SPEED, + le=MAX_INITIAL_SPEED, + description="Engine ticks per pattern line.", + ) + chip_type: str = Field( + default=CHIP_TYPE_NES, + description="Chip the song drives.", + ) + chip_variant: ChipVariant = Field( + default=DEFAULT_CHIP_VARIANT, + description="System whose CPU clock applies.", + ) + chip_frequency: int = Field( + ..., + description="CPU clock in Hz the tuning table was built from.", + ) + interrupt_frequency: int = Field( + ..., + description="Engine tick rate in Hz.", + ) + a4_tuning_hz: float = Field( + default=DEFAULT_A4_TUNING, + description="Concert pitch the tuning table centres on.", + ) + virtual_channel_map: Dict[int, int] = Field( + default_factory=dict, + description="Extra channels folded onto hardware ones.", + ) diff --git a/src/sampletones_core/formats/bitphase/model/table.py b/src/sampletones_core/formats/bitphase/model/table.py new file mode 100644 index 00000000..c2f3d3d0 --- /dev/null +++ b/src/sampletones_core/formats/bitphase/model/table.py @@ -0,0 +1,41 @@ +from typing import Tuple + +from pydantic import BaseModel, Field + +from sampletones_core.formats.bitphase.model.config import BITPHASE_MODEL_CONFIG +from sampletones_core.formats.bitphase.specification.instruments import ( + LOOP_FROM_START, + MAX_TABLE_ID, + MIN_TABLE_ID, +) + + +class BitphaseTable(BaseModel): + """A per-tick semitone contour a pattern cell attaches to a channel. + + Playback adds ``rows[position]`` to the channel's note every tick, advancing one + row per tick, so a table carries the pitch movement a reconstruction's arpeggio + envelope describes. + """ + + model_config = BITPHASE_MODEL_CONFIG + + id: int = Field( + ..., + ge=MIN_TABLE_ID, + le=MAX_TABLE_ID, + description="Identifier a pattern's table column names.", + ) + rows: Tuple[int, ...] = Field( + ..., + description="Semitone offset applied on each tick.", + ) + loop: int = Field( + default=LOOP_FROM_START, + ge=0, + description="Row playback returns to after the last row.", + ) + name: str = Field( + ..., + description="Name shown in the table list.", + ) diff --git a/src/sampletones_core/formats/bitphase/notes.py b/src/sampletones_core/formats/bitphase/notes.py new file mode 100644 index 00000000..0bc9e873 --- /dev/null +++ b/src/sampletones_core/formats/bitphase/notes.py @@ -0,0 +1,75 @@ +from sampletones_core.constants.general import NUM_PERIODS +from sampletones_core.formats.bitphase.model.pattern import NoteCell +from sampletones_core.formats.bitphase.specification.patterns import ( + FIRST_OCTAVE, + MAX_NOTE_INDEX, + MIN_NOTE_INDEX, + NOISE_BASE_NOTE_INDEX, + NOTE_INDEX_PITCH_OFFSET, + NOTE_RANGE, + NoteName, +) + + +def pitch_to_note_index(pitch: int) -> int: + """Converts an absolute pitch to the note index Bitphase tunes from. + + The index is clamped to the span the 96-entry tuning table covers, so an extreme + transposition lands on the nearest playable note. + + Args: + pitch: Absolute pitch, on the same scale the reconstruction records. + + Returns: + int: Index into the tuning table. + """ + index = pitch - NOTE_INDEX_PITCH_OFFSET + return max(MIN_NOTE_INDEX, min(MAX_NOTE_INDEX, index)) + + +def note_index_to_note_cell(index: int) -> NoteCell: + """Converts a tuning-table index to the note and octave a pattern cell stores. + + Args: + index: Index into the tuning table. + + Returns: + NoteCell: The note column playback resolves back to ``index``. + """ + name = index % NOTE_RANGE + int(NoteName.C) + octave = index // NOTE_RANGE + FIRST_OCTAVE + return NoteCell(name=name, octave=octave) + + +def noise_period_to_note_index(period: int) -> int: + """Converts a noise period index to the note index that selects it. + + Playback reads a noise note as ``15 - (index mod 16)``, so every period repeats once + per sixteen note indices and any of those indices selects it. The base index sits + far enough below the top of the tuning table that a whole cycle of table offsets + stays in range. + + Args: + period: Noise period index the reconstruction chose. + + Returns: + int: Note index whose noise period equals ``period``. + """ + offset = (NUM_PERIODS - 1 - period) % NUM_PERIODS + return NOISE_BASE_NOTE_INDEX + offset + + +def noise_arpeggio_to_table_offset(step: int) -> int: + """Converts a noise arpeggio step to the semitone offset a table row carries. + + A rising noise period is a falling note index, so the step is negated and wrapped + into one period cycle, which keeps every note the table reaches inside the tuning + table. + + Args: + step: Period offset from the reconstruction's initial noise period. + + Returns: + int: Semitone offset that moves the noise period by ``step``. + """ + return (-step) % NUM_PERIODS diff --git a/src/sampletones_core/formats/bitphase/preset.py b/src/sampletones_core/formats/bitphase/preset.py new file mode 100644 index 00000000..fbd1a048 --- /dev/null +++ b/src/sampletones_core/formats/bitphase/preset.py @@ -0,0 +1,106 @@ +import json +from pathlib import Path +from typing import Final, Sequence, Tuple + +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.formats.bitphase.envelopes import features_to_envelopes +from sampletones_core.formats.bitphase.model.instrument import BitphaseInstrumentPreset, NesInstrumentRow +from sampletones_core.formats.bitphase.notes import pitch_to_note_index +from sampletones_core.formats.bitphase.specification.chip import DEFAULT_A4_TUNING, DEFAULT_CPU_FREQUENCY +from sampletones_core.formats.bitphase.specification.instruments import ( + MAX_TONE_ADD, + MIN_TONE_ADD, + NO_TONE_OFFSET, +) +from sampletones_core.formats.bitphase.specification.patterns import MAX_NOTE_INDEX, MIN_NOTE_INDEX +from sampletones_core.formats.bitphase.tuning import generate_tuning_table +from sampletones_core.trackers.request import InstrumentExport + +PRESET_TUNING_TABLE: Final[Tuple[int, ...]] = generate_tuning_table( + DEFAULT_CPU_FREQUENCY, + a4_tuning=DEFAULT_A4_TUNING, +) +PRESET_JSON_INDENT: Final[int] = 2 + + +def _tone_offsets( + generator: GeneratorName, + initial_pitch: int, + contour: Sequence[int], +) -> Tuple[int, ...]: + """Expresses a semitone contour as the per-tick period offsets a preset carries. + + A preset holds rows alone, so its pitch movement rides in each row's tone offset. + The offsets are measured against the pitch the slice was reconstructed at, under the + tuning the NTSC system gives at concert pitch, which is what a freshly created + Bitphase document plays. The noise channel takes its period from the note rather + than from a period offset, so its rows hold a flat offset and the note carries the + pitch. + """ + if generator == GeneratorName.NOISE: + return (NO_TONE_OFFSET,) * len(contour) + + base_index = pitch_to_note_index(initial_pitch) + base_period = PRESET_TUNING_TABLE[base_index] + + offsets = [] + for semitones in contour: + index = max(MIN_NOTE_INDEX, min(MAX_NOTE_INDEX, base_index + semitones)) + offset = PRESET_TUNING_TABLE[index] - base_period + offsets.append(max(MIN_TONE_ADD, min(MAX_TONE_ADD, offset))) + + return tuple(offsets) + + +def instrument_to_preset(request: InstrumentExport) -> BitphaseInstrumentPreset: + """Builds the single-instrument file Bitphase's instruments panel loads. + + Args: + request: The generator slice to write. + + Returns: + BitphaseInstrumentPreset: The instrument to serialize. + """ + envelopes = features_to_envelopes( + request.features, + request.generator, + loop=request.loop, + ) + offsets = _tone_offsets( + request.generator, + request.features.initial_pitch, + envelopes.table_rows, + ) + rows: Tuple[NesInstrumentRow, ...] = tuple( + row.model_copy(update={"tone_add": offset}) + for row, offset in zip( + envelopes.rows, + offsets, + ) + ) + + return BitphaseInstrumentPreset( + name=request.name, + loop=envelopes.loop, + rows=rows, + ) + + +def write_preset(destination: Path, preset: BitphaseInstrumentPreset) -> None: + """Writes a Bitphase instrument preset to disk. + + The file is indented the way Bitphase writes its own, so a preset dropped into the + tracker's preset tree reads like the ones already there. + + Args: + destination: The file to write. + preset: The instrument to serialize. + + Raises: + OSError: If the destination cannot be written. + """ + payload = json.dumps( + preset.model_dump(mode="json", by_alias=True), + indent=PRESET_JSON_INDENT, + ) + destination.write_text(payload, encoding="utf-8") diff --git a/tests/unit/sampletones_core/famitracker/sequences/__init__.py b/src/sampletones_core/formats/bitphase/specification/__init__.py similarity index 100% rename from tests/unit/sampletones_core/famitracker/sequences/__init__.py rename to src/sampletones_core/formats/bitphase/specification/__init__.py diff --git a/src/sampletones_core/formats/bitphase/specification/channels.py b/src/sampletones_core/formats/bitphase/specification/channels.py new file mode 100644 index 00000000..838a6f55 --- /dev/null +++ b/src/sampletones_core/formats/bitphase/specification/channels.py @@ -0,0 +1,31 @@ +from enum import IntEnum +from typing import Dict, Final, Tuple + +from sampletones_core.constants.enums import GeneratorName + + +class ChannelIndex(IntEnum): + """Position each 2A03 channel takes in a pattern's channel list.""" + + SQUARE1 = 0 + SQUARE2 = 1 + TRIANGLE = 2 + NOISE = 3 + DPCM = 4 + + +CHANNEL_LABELS: Final[Tuple[str, ...]] = ( + "Square 1", + "Square 2", + "Triangle", + "Noise", + "DPCM", +) +CHANNEL_COUNT: Final[int] = len(CHANNEL_LABELS) + +GENERATOR_NAME_TO_CHANNEL_INDEX: Final[Dict[GeneratorName, ChannelIndex]] = { + GeneratorName.PULSE1: ChannelIndex.SQUARE1, + GeneratorName.PULSE2: ChannelIndex.SQUARE2, + GeneratorName.TRIANGLE: ChannelIndex.TRIANGLE, + GeneratorName.NOISE: ChannelIndex.NOISE, +} diff --git a/src/sampletones_core/formats/bitphase/specification/chip.py b/src/sampletones_core/formats/bitphase/specification/chip.py new file mode 100644 index 00000000..24dd1152 --- /dev/null +++ b/src/sampletones_core/formats/bitphase/specification/chip.py @@ -0,0 +1,35 @@ +from enum import StrEnum +from typing import Dict, Final + +from sampletones_core.constants.general import A4_FREQUENCY, APU_CLOCK + +CHIP_TYPE_NES: Final[str] = "nes" + + +class ChipVariant(StrEnum): + """NES system whose CPU clock drives the tuning table.""" + + NTSC = "NTSC" + PAL = "PAL" + DENDY = "Dendy" + + +CPU_FREQUENCIES: Final[Dict[ChipVariant, int]] = { + ChipVariant.NTSC: int(APU_CLOCK), + ChipVariant.PAL: 1_662_607, + ChipVariant.DENDY: 1_773_448, +} + +DEFAULT_CHIP_VARIANT: Final[ChipVariant] = ChipVariant.NTSC +DEFAULT_CPU_FREQUENCY: Final[int] = CPU_FREQUENCIES[DEFAULT_CHIP_VARIANT] + +TUNING_TABLE_LENGTH: Final[int] = 96 +TUNING_A4_INDEX: Final[int] = 45 +TUNING_PERIOD_DIVISOR: Final[int] = 16 +MIN_TUNING_PERIOD: Final[int] = 1 +MAX_TUNING_PERIOD: Final[int] = 2047 + +DEFAULT_A4_TUNING: Final[float] = A4_FREQUENCY + +MIN_INITIAL_SPEED: Final[int] = 1 +MAX_INITIAL_SPEED: Final[int] = 255 diff --git a/src/sampletones_core/formats/bitphase/specification/instruments.py b/src/sampletones_core/formats/bitphase/specification/instruments.py new file mode 100644 index 00000000..2dfb0691 --- /dev/null +++ b/src/sampletones_core/formats/bitphase/specification/instruments.py @@ -0,0 +1,50 @@ +from typing import Final + +from sampletones_core.constants.general import MAX_DUTY_CYCLE, MAX_VOLUME +from sampletones_core.formats.bitphase.specification.patterns import ( + SYMBOL_BASE, + TABLE_COLUMN_OFFSET, +) + +INSTRUMENT_ID_DIGITS: Final[int] = 2 +MIN_INSTRUMENT_ID: Final[int] = 1 +MAX_INSTRUMENT_ID: Final[int] = SYMBOL_BASE**INSTRUMENT_ID_DIGITS - 1 + +TABLE_COLUMN_DIGITS: Final[int] = 1 +MAX_TABLE_COLUMN: Final[int] = SYMBOL_BASE**TABLE_COLUMN_DIGITS - 1 +MIN_TABLE_ID: Final[int] = 0 +MAX_TABLE_ID: Final[int] = MAX_TABLE_COLUMN - TABLE_COLUMN_OFFSET + +MIN_PULSE_WIDTH: Final[int] = 0 +MAX_PULSE_WIDTH: Final[int] = MAX_DUTY_CYCLE +FLAT_PULSE_WIDTH: Final[int] = 0 + +MIN_VOLUME_OR_RATE: Final[int] = 0 +MAX_VOLUME_OR_RATE: Final[int] = MAX_VOLUME +SILENT_VOLUME: Final[int] = 0 + +NOISE_MODE_LONG: Final[int] = 0 +NOISE_MODE_SHORT: Final[int] = 1 + +MIN_SOUND_LENGTH: Final[int] = 0 +MAX_SOUND_LENGTH: Final[int] = 511 +SUSTAINED_SOUND_LENGTH: Final[int] = 0 + +MIN_TONE_ADD: Final[int] = -4096 +MAX_TONE_ADD: Final[int] = 4095 +NO_TONE_OFFSET: Final[int] = 0 + +MIN_SWEEP_RATE: Final[int] = 0 +MAX_SWEEP_RATE: Final[int] = 7 +MIN_SWEEP_SHIFT: Final[int] = -7 +MAX_SWEEP_SHIFT: Final[int] = 7 +NO_SWEEP_RATE: Final[int] = 0 +NO_SWEEP_SHIFT: Final[int] = 0 + +CONSTANT_VOLUME: Final[bool] = False +ABSOLUTE_TONE: Final[bool] = False +KEEP_PHASE: Final[bool] = False +NO_SWEEP: Final[bool] = False + +LOOP_FROM_START: Final[int] = 0 +NO_TABLE_OFFSET: Final[int] = 0 diff --git a/src/sampletones_core/formats/bitphase/specification/patterns.py b/src/sampletones_core/formats/bitphase/specification/patterns.py new file mode 100644 index 00000000..3601cac7 --- /dev/null +++ b/src/sampletones_core/formats/bitphase/specification/patterns.py @@ -0,0 +1,43 @@ +from enum import IntEnum +from typing import Final + +from sampletones_core.formats.bitphase.specification.chip import TUNING_TABLE_LENGTH + + +class NoteName(IntEnum): + """Reserved values of a pattern cell's note column. + + Pitched notes occupy ``2``..``13`` (C..B); the values below are the non-pitched + markers. + """ + + NONE = 0 + OFF = 1 + C = 2 + B = 13 + + +NOTE_RANGE: Final[int] = 12 +FIRST_OCTAVE: Final[int] = 1 +EMPTY_OCTAVE: Final[int] = 0 + +MIN_NOTE_INDEX: Final[int] = 0 +MAX_NOTE_INDEX: Final[int] = TUNING_TABLE_LENGTH - 1 +NOTE_INDEX_PITCH_OFFSET: Final[int] = 24 +NOISE_BASE_NOTE_INDEX: Final[int] = 48 + +SYMBOL_BASE: Final[int] = 36 +SYMBOL_DIGITS: Final[str] = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" + +NO_INSTRUMENT_CHANGE: Final[int] = 0 +NO_TABLE_CHANGE: Final[int] = 0 +TABLE_OFF: Final[int] = -1 +TABLE_COLUMN_OFFSET: Final[int] = 1 + +NO_VOLUME_CHANGE: Final[int] = 0 +FULL_VOLUME: Final[int] = 15 + +MIN_PATTERN_LENGTH: Final[int] = 1 +MAX_PATTERN_LENGTH: Final[int] = 256 + +FIRST_PATTERN_ID: Final[int] = 0 diff --git a/src/sampletones_core/formats/bitphase/tuning.py b/src/sampletones_core/formats/bitphase/tuning.py new file mode 100644 index 00000000..7c907759 --- /dev/null +++ b/src/sampletones_core/formats/bitphase/tuning.py @@ -0,0 +1,41 @@ +import math +from typing import Tuple + +from sampletones_core.formats.bitphase.specification.chip import ( + MAX_TUNING_PERIOD, + MIN_TUNING_PERIOD, + TUNING_A4_INDEX, + TUNING_PERIOD_DIVISOR, + TUNING_TABLE_LENGTH, +) +from sampletones_core.formats.bitphase.specification.patterns import NOTE_RANGE + + +def generate_tuning_table( + chip_frequency: int, + *, + a4_tuning: float, + max_period: int = MAX_TUNING_PERIOD, +) -> Tuple[int, ...]: + """Builds the channel period Bitphase plays for each of its 96 note indices. + + Each index is one equal-tempered semitone, measured from the concert pitch that + sits at index 45, and its period is the CPU clock divided by the timer's own + divisor and the note's frequency. Rounding matches the tracker's, so a table built + here equals the one Bitphase derives from the same settings. + + Args: + chip_frequency: CPU clock in Hz. + a4_tuning: Frequency in Hz of the note at the concert-pitch index. + max_period: Longest period the channel timer holds. + + Returns: + Tuple[int, ...]: One period per note index, held within the timer's range. + """ + periods = [] + for index in range(TUNING_TABLE_LENGTH): + frequency = a4_tuning * 2 ** ((index - TUNING_A4_INDEX) / NOTE_RANGE) + period = math.floor(chip_frequency / TUNING_PERIOD_DIVISOR / frequency + 0.5) + periods.append(max(MIN_TUNING_PERIOD, min(max_period, period))) + + return tuple(periods) diff --git a/src/sampletones_core/formats/famitracker/__init__.py b/src/sampletones_core/formats/famitracker/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/sampletones_core/famitracker/binary.py b/src/sampletones_core/formats/famitracker/binary.py similarity index 96% rename from src/sampletones_core/famitracker/binary.py rename to src/sampletones_core/formats/famitracker/binary.py index e111b118..ff778b90 100644 --- a/src/sampletones_core/famitracker/binary.py +++ b/src/sampletones_core/formats/famitracker/binary.py @@ -4,7 +4,7 @@ from contextlib import contextmanager from typing import Iterator -from sampletones_core.famitracker.specification.blocks import BLOCK_NAME_LENGTH, Block +from sampletones_core.formats.famitracker.specification.blocks import BLOCK_NAME_LENGTH, Block class BinaryWriter: diff --git a/src/sampletones_core/famitracker/builder.py b/src/sampletones_core/formats/famitracker/builder.py similarity index 69% rename from src/sampletones_core/famitracker/builder.py rename to src/sampletones_core/formats/famitracker/builder.py index 1dcc6d66..5cfa6259 100644 --- a/src/sampletones_core/famitracker/builder.py +++ b/src/sampletones_core/formats/famitracker/builder.py @@ -1,31 +1,35 @@ from __future__ import annotations -from dataclasses import dataclass -from typing import Dict, List, Optional, Tuple +from typing import List, Optional, Tuple from sampletones_core.constants.enums import GeneratorName -from sampletones_core.famitracker.model.instrument import Instrument2A03 -from sampletones_core.famitracker.model.module import ( +from sampletones_core.exporters.slices import ( + InstrumentSlot, + InstrumentTable, + iterate_sample_slices, +) +from sampletones_core.formats.famitracker.model.instrument import Instrument2A03 +from sampletones_core.formats.famitracker.model.module import ( FamiTrackerModule, ModuleInformation, ModuleParameters, OrderFrame, Track, ) -from sampletones_core.famitracker.model.pattern import PatternData, RowCell -from sampletones_core.famitracker.notes import ( +from sampletones_core.formats.famitracker.model.pattern import PatternData, RowCell +from sampletones_core.formats.famitracker.notes import ( period_to_note_cell, pitch_to_note_cell, resolve_machine, ) -from sampletones_core.famitracker.sequences.features import features_to_instrument_sequences -from sampletones_core.famitracker.specification.channels import ( +from sampletones_core.formats.famitracker.sequences.features import features_to_instrument_sequences +from sampletones_core.formats.famitracker.specification.channels import ( CHANNEL_COUNT_2A03, GENERATOR_NAME_TO_CHANNEL_ID, ChannelId, ) -from sampletones_core.famitracker.specification.instruments import MAX_INSTRUMENTS -from sampletones_core.famitracker.specification.parameters import ( +from sampletones_core.formats.famitracker.specification.instruments import MAX_INSTRUMENTS +from sampletones_core.formats.famitracker.specification.parameters import ( DEFAULT_COPYRIGHT, DEFAULT_HIGHLIGHT_FIRST, DEFAULT_HIGHLIGHT_SECOND, @@ -33,7 +37,7 @@ DEFAULT_VIBRATO_STYLE, EXPANSION_NONE, ) -from sampletones_core.famitracker.specification.patterns import ( +from sampletones_core.formats.famitracker.specification.patterns import ( DEFAULT_EFFECT_COLUMNS, DPCM_EMPTY_PATTERN_INDEX, EMPTY_EFFECT, @@ -54,17 +58,6 @@ from sampletones_core.project.song import Song -@dataclass(frozen=True) -class InstrumentSlot: - """Where a sample's generator slice landed in the instrument table.""" - - index: int - initial_pitch: int - - -InstrumentTable = Dict[Tuple[str, GeneratorName], InstrumentSlot] - - def build_instrument_table(project: Project) -> Tuple[List[Instrument2A03], InstrumentTable]: """Builds one FamiTracker instrument per generator slice of every sample. @@ -75,28 +68,27 @@ def build_instrument_table(project: Project) -> Tuple[List[Instrument2A03], Inst instruments: List[Instrument2A03] = [] slots: InstrumentTable = {} - for sample in project.samples: - features_by_generator = sample.reconstruction.export() - for generator in GeneratorName.items(): - features = features_by_generator.get(generator) - if features is None: - continue - - index = len(instruments) - if index >= MAX_INSTRUMENTS: - raise ValueError(f"Module exceeds the FamiTracker limit of {MAX_INSTRUMENTS} instruments") - - sequences = features_to_instrument_sequences( - volume=features.volume, - arpeggio=features.arpeggio, - pitch=features.pitch, - hi_pitch=features.hi_pitch, - duty_cycle=features.duty_cycle, - loop=sample.loop, + for sample_slice in iterate_sample_slices(project): + if sample_slice.index >= MAX_INSTRUMENTS: + raise ValueError(f"Module exceeds the FamiTracker limit of {MAX_INSTRUMENTS} instruments") + + features = sample_slice.features + sequences = features_to_instrument_sequences( + volume=features.volume, + arpeggio=features.arpeggio, + pitch=features.pitch, + hi_pitch=features.hi_pitch, + duty_cycle=features.duty_cycle, + loop=sample_slice.sample.loop, + ) + instruments.append( + Instrument2A03( + index=sample_slice.index, + name=sample_slice.instrument_name, + sequences=sequences, ) - name = f"{sample.name} {generator.capitalized}" - instruments.append(Instrument2A03(index=index, name=name, sequences=sequences)) - slots[(sample.id, generator)] = InstrumentSlot(index=index, initial_pitch=features.initial_pitch) + ) + slots[sample_slice.key] = sample_slice.slot return instruments, slots @@ -137,7 +129,12 @@ def _row_cell( f"'{reference.generator_name}' that has no instrument" ) instrument = slot.index - note, octave = _note_and_octave(reference, row.transpose or 0, channel_generator, slot) + note, octave = _note_and_octave( + reference, + row.transpose or 0, + channel_generator, + slot, + ) case None: pass @@ -162,7 +159,11 @@ def _has_data(cell: RowCell) -> bool: ) -def _channel_patterns(generator: GeneratorName, channel: Channel, slots: InstrumentTable) -> List[PatternData]: +def _channel_patterns( + generator: GeneratorName, + channel: Channel, + slots: InstrumentTable, +) -> List[PatternData]: channel_id = GENERATOR_NAME_TO_CHANNEL_ID[generator] patterns: List[PatternData] = [] @@ -173,11 +174,25 @@ def _channel_patterns(generator: GeneratorName, channel: Channel, slots: Instrum pattern = channel.patterns[index] rows = tuple( cell - for cell in (_row_cell(row, row_number, generator, slots) for row_number, row in enumerate(pattern.rows)) + for cell in ( + _row_cell( + row, + row_number, + generator, + slots, + ) + for row_number, row in enumerate(pattern.rows) + ) if cell is not None ) if rows: - patterns.append(PatternData(channel=channel_id, index=index, rows=rows)) + patterns.append( + PatternData( + channel=channel_id, + index=index, + rows=rows, + ) + ) return patterns @@ -227,11 +242,17 @@ def project_to_module(project: Project) -> FamiTrackerModule: highlight_second=DEFAULT_HIGHLIGHT_SECOND, speed_split_point=DEFAULT_SPEED_SPLIT_POINT, ) - information = ModuleInformation(title=info.title, author=info.author, copyright=DEFAULT_COPYRIGHT) + information = ModuleInformation( + title=info.title, + author=info.author, + copyright=DEFAULT_COPYRIGHT, + ) patterns: List[PatternData] = [] for generator in GeneratorName.items(): - patterns.extend(_channel_patterns(generator, song.channels[generator], slots)) + patterns.extend( + _channel_patterns(generator, song.channels[generator], slots), + ) track = Track( title=info.title, diff --git a/src/sampletones_core/famitracker/export.py b/src/sampletones_core/formats/famitracker/export.py similarity index 70% rename from src/sampletones_core/famitracker/export.py rename to src/sampletones_core/formats/famitracker/export.py index e551afe5..16a18538 100644 --- a/src/sampletones_core/famitracker/export.py +++ b/src/sampletones_core/formats/famitracker/export.py @@ -1,7 +1,5 @@ -from __future__ import annotations - -from sampletones_core.famitracker.builder import project_to_module -from sampletones_core.famitracker.ftm import module_to_ftm_bytes +from sampletones_core.formats.famitracker.builder import project_to_module +from sampletones_core.formats.famitracker.module import module_to_ftm_bytes from sampletones_core.project.project import Project from sampletones_shared.types.path import Pathlike from sampletones_shared.utils.serialization import save_binary diff --git a/src/sampletones_core/famitracker/fti.py b/src/sampletones_core/formats/famitracker/instrument.py similarity index 72% rename from src/sampletones_core/famitracker/fti.py rename to src/sampletones_core/formats/famitracker/instrument.py index 5b7ee6e8..4592e8ad 100644 --- a/src/sampletones_core/famitracker/fti.py +++ b/src/sampletones_core/formats/famitracker/instrument.py @@ -1,15 +1,13 @@ -from __future__ import annotations - -from sampletones_core.famitracker.binary import BinaryWriter -from sampletones_core.famitracker.model.instrument import Instrument2A03 -from sampletones_core.famitracker.model.sequence import InstrumentSequence -from sampletones_core.famitracker.specification.file import FTI_MAGIC, FTI_VERSION -from sampletones_core.famitracker.specification.instruments import ( +from sampletones_core.formats.famitracker.binary import BinaryWriter +from sampletones_core.formats.famitracker.model.instrument import Instrument2A03 +from sampletones_core.formats.famitracker.model.sequence import InstrumentSequence +from sampletones_core.formats.famitracker.specification.file import FTI_MAGIC, FTI_VERSION +from sampletones_core.formats.famitracker.specification.instruments import ( EMPTY_DPCM_ASSIGNMENTS, EMPTY_DPCM_SAMPLES, INSTRUMENT_TYPE_2A03, ) -from sampletones_core.famitracker.specification.sequences import ( +from sampletones_core.formats.famitracker.specification.sequences import ( SEQUENCE_COUNT_2A03, SEQUENCE_DISABLED, SEQUENCE_ENABLED, @@ -24,12 +22,18 @@ def _write_header(writer: BinaryWriter) -> None: writer.write_bytes(FTI_VERSION) -def _write_type_and_name(writer: BinaryWriter, instrument: Instrument2A03) -> None: +def _write_type_and_name( + writer: BinaryWriter, + instrument: Instrument2A03, +) -> None: writer.write_uint8(INSTRUMENT_TYPE_2A03) writer.write_counted_string(instrument.name) -def _write_sequence(writer: BinaryWriter, sequence: InstrumentSequence) -> None: +def _write_sequence( + writer: BinaryWriter, + sequence: InstrumentSequence, +) -> None: if not sequence.enabled: writer.write_int8(SEQUENCE_DISABLED) return diff --git a/src/sampletones_core/formats/famitracker/model/__init__.py b/src/sampletones_core/formats/famitracker/model/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/sampletones_core/famitracker/model/instrument.py b/src/sampletones_core/formats/famitracker/model/instrument.py similarity index 76% rename from src/sampletones_core/famitracker/model/instrument.py rename to src/sampletones_core/formats/famitracker/model/instrument.py index f9f39333..a29c492d 100644 --- a/src/sampletones_core/famitracker/model/instrument.py +++ b/src/sampletones_core/formats/famitracker/model/instrument.py @@ -4,8 +4,8 @@ from pydantic import BaseModel, ConfigDict -from sampletones_core.famitracker.model.sequence import InstrumentSequence -from sampletones_core.famitracker.specification.sequences import SequenceKind +from sampletones_core.formats.famitracker.model.sequence import InstrumentSequence +from sampletones_core.formats.famitracker.specification.sequences import SequenceKind class Instrument2A03(BaseModel): diff --git a/src/sampletones_core/famitracker/model/module.py b/src/sampletones_core/formats/famitracker/model/module.py similarity index 81% rename from src/sampletones_core/famitracker/model/module.py rename to src/sampletones_core/formats/famitracker/model/module.py index e32ef10d..a03dc61b 100644 --- a/src/sampletones_core/famitracker/model/module.py +++ b/src/sampletones_core/formats/famitracker/model/module.py @@ -4,10 +4,10 @@ from pydantic import BaseModel, ConfigDict -from sampletones_core.famitracker.model.instrument import Instrument2A03 -from sampletones_core.famitracker.model.pattern import PatternData -from sampletones_core.famitracker.specification.channels import ChannelId -from sampletones_core.famitracker.specification.parameters import Machine +from sampletones_core.formats.famitracker.model.instrument import Instrument2A03 +from sampletones_core.formats.famitracker.model.pattern import PatternData +from sampletones_core.formats.famitracker.specification.channels import ChannelId +from sampletones_core.formats.famitracker.specification.parameters import Machine OrderFrame = Tuple[int, ...] """One order position: the pattern index each channel plays, in channel-id order.""" diff --git a/src/sampletones_core/famitracker/model/pattern.py b/src/sampletones_core/formats/famitracker/model/pattern.py similarity index 92% rename from src/sampletones_core/famitracker/model/pattern.py rename to src/sampletones_core/formats/famitracker/model/pattern.py index 60212ac5..6dfff230 100644 --- a/src/sampletones_core/famitracker/model/pattern.py +++ b/src/sampletones_core/formats/famitracker/model/pattern.py @@ -4,7 +4,7 @@ from pydantic import BaseModel, ConfigDict -from sampletones_core.famitracker.specification.channels import ChannelId +from sampletones_core.formats.famitracker.specification.channels import ChannelId class NoteCell(BaseModel): diff --git a/src/sampletones_core/famitracker/model/sequence.py b/src/sampletones_core/formats/famitracker/model/sequence.py similarity index 91% rename from src/sampletones_core/famitracker/model/sequence.py rename to src/sampletones_core/formats/famitracker/model/sequence.py index 8c724544..77fa4a69 100644 --- a/src/sampletones_core/famitracker/model/sequence.py +++ b/src/sampletones_core/formats/famitracker/model/sequence.py @@ -1,10 +1,8 @@ -from __future__ import annotations - from typing import Tuple from pydantic import BaseModel, ConfigDict, computed_field, field_validator -from sampletones_core.famitracker.specification.sequences import ( +from sampletones_core.formats.famitracker.specification.sequences import ( DEFAULT_SEQUENCE_SETTING, MAX_SEQUENCE_ITEMS, NO_LOOP_POINT, @@ -32,7 +30,10 @@ class InstrumentSequence(BaseModel): @field_validator("items") @classmethod - def _fits_famitracker_sequence(cls, items: Tuple[int, ...]) -> Tuple[int, ...]: + def _fits_famitracker_sequence( + cls, + items: Tuple[int, ...], + ) -> Tuple[int, ...]: """Keeps an instance within the item count FamiTracker can represent. FamiTracker holds a sequence in a fixed 252-entry array and stores the count in a diff --git a/src/sampletones_core/famitracker/ftm.py b/src/sampletones_core/formats/famitracker/module.py similarity index 87% rename from src/sampletones_core/famitracker/ftm.py rename to src/sampletones_core/formats/famitracker/module.py index 99d871db..fc88d49a 100644 --- a/src/sampletones_core/famitracker/ftm.py +++ b/src/sampletones_core/formats/famitracker/module.py @@ -1,22 +1,20 @@ -from __future__ import annotations - from typing import Sequence -from sampletones_core.famitracker.binary import BinaryWriter -from sampletones_core.famitracker.model.instrument import Instrument2A03 -from sampletones_core.famitracker.model.module import ( +from sampletones_core.formats.famitracker.binary import BinaryWriter +from sampletones_core.formats.famitracker.model.instrument import Instrument2A03 +from sampletones_core.formats.famitracker.model.module import ( FamiTrackerModule, ModuleInformation, ModuleParameters, Track, ) -from sampletones_core.famitracker.model.pattern import PatternData -from sampletones_core.famitracker.sequences.pooled import PooledSequence -from sampletones_core.famitracker.sequences.pooling import ( +from sampletones_core.formats.famitracker.model.pattern import PatternData +from sampletones_core.formats.famitracker.sequences.pooled import PooledSequence +from sampletones_core.formats.famitracker.sequences.pooling import ( SequenceReferences, build_sequence_pool, ) -from sampletones_core.famitracker.specification.blocks import ( +from sampletones_core.formats.famitracker.specification.blocks import ( BLOCK_COMMENTS, BLOCK_DPCM_SAMPLES, BLOCK_FRAMES, @@ -27,25 +25,25 @@ BLOCK_PATTERNS, BLOCK_SEQUENCES, ) -from sampletones_core.famitracker.specification.channels import ChannelId -from sampletones_core.famitracker.specification.file import ( +from sampletones_core.formats.famitracker.specification.channels import ChannelId +from sampletones_core.formats.famitracker.specification.file import ( FTM_END_MARKER, FTM_MAGIC, FTM_VERSION, INFO_STRING_LENGTH, ) -from sampletones_core.famitracker.specification.instruments import ( +from sampletones_core.formats.famitracker.specification.instruments import ( DPCM_KEY_ASSIGNMENTS, DPCM_KEY_BYTES, EMPTY_DPCM_SAMPLES, INSTRUMENT_TYPE_2A03, ) -from sampletones_core.famitracker.specification.parameters import ( +from sampletones_core.formats.famitracker.specification.parameters import ( COMMENT_HIDDEN_ON_OPEN, FIRST_TRACK_INDEX, SINGLE_TRACK_COUNT, ) -from sampletones_core.famitracker.specification.sequences import ( +from sampletones_core.formats.famitracker.specification.sequences import ( SEQUENCE_COUNT_2A03, SEQUENCE_DISABLED, SEQUENCE_ENABLED, diff --git a/src/sampletones_core/famitracker/notes.py b/src/sampletones_core/formats/famitracker/notes.py similarity index 87% rename from src/sampletones_core/famitracker/notes.py rename to src/sampletones_core/formats/famitracker/notes.py index f2aa7073..dc05f0d9 100644 --- a/src/sampletones_core/famitracker/notes.py +++ b/src/sampletones_core/formats/famitracker/notes.py @@ -1,16 +1,14 @@ -from __future__ import annotations - from typing import Tuple from sampletones_core.constants.general import NUM_PERIODS -from sampletones_core.famitracker.model.pattern import NoteCell -from sampletones_core.famitracker.specification.parameters import ( +from sampletones_core.formats.famitracker.model.pattern import NoteCell +from sampletones_core.formats.famitracker.specification.parameters import ( ENGINE_SPEED_MACHINE_DEFAULT, NTSC_FREQUENCY, PAL_FREQUENCY, Machine, ) -from sampletones_core.famitracker.specification.patterns import ( +from sampletones_core.formats.famitracker.specification.patterns import ( FT_MAX_PITCH, FT_MIN_PITCH, NOTE_RANGE, diff --git a/src/sampletones_core/formats/famitracker/sequences/__init__.py b/src/sampletones_core/formats/famitracker/sequences/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/sampletones_core/famitracker/sequences/features.py b/src/sampletones_core/formats/famitracker/sequences/features.py similarity index 80% rename from src/sampletones_core/famitracker/sequences/features.py rename to src/sampletones_core/formats/famitracker/sequences/features.py index 83ff2b70..75d88510 100644 --- a/src/sampletones_core/famitracker/sequences/features.py +++ b/src/sampletones_core/formats/famitracker/sequences/features.py @@ -2,10 +2,11 @@ import numpy as np -from sampletones_core.famitracker.model.sequence import InstrumentSequence -from sampletones_core.famitracker.sequences.lengths import equalize_lengths -from sampletones_core.famitracker.specification.sequences import ( +from sampletones_core.exporters.lengths import equalize_lengths +from sampletones_core.formats.famitracker.model.sequence import InstrumentSequence +from sampletones_core.formats.famitracker.specification.sequences import ( LOOP_FROM_START, + MAX_SEQUENCE_ITEMS, NO_LOOP_POINT, SequenceKind, ) @@ -43,7 +44,11 @@ def features_to_instrument_sequences( SequenceKind.DUTY: duty_cycle, } - items_by_kind = equalize_lengths({kind: _to_items(array) for kind, array in arrays.items()}, loop) + items_by_kind = equalize_lengths( + {kind: _to_items(array) for kind, array in arrays.items()}, + loop, + limit=MAX_SEQUENCE_ITEMS, + ) sequences: Dict[SequenceKind, InstrumentSequence] = {} for kind, items in items_by_kind.items(): diff --git a/src/sampletones_core/famitracker/sequences/pooled.py b/src/sampletones_core/formats/famitracker/sequences/pooled.py similarity index 57% rename from src/sampletones_core/famitracker/sequences/pooled.py rename to src/sampletones_core/formats/famitracker/sequences/pooled.py index f0781466..04efcd5c 100644 --- a/src/sampletones_core/famitracker/sequences/pooled.py +++ b/src/sampletones_core/formats/famitracker/sequences/pooled.py @@ -1,7 +1,7 @@ from dataclasses import dataclass -from sampletones_core.famitracker.model.sequence import InstrumentSequence -from sampletones_core.famitracker.specification.sequences import SequenceKind +from sampletones_core.formats.famitracker.model.sequence import InstrumentSequence +from sampletones_core.formats.famitracker.specification.sequences import SequenceKind @dataclass(frozen=True) diff --git a/src/sampletones_core/famitracker/sequences/pooling.py b/src/sampletones_core/formats/famitracker/sequences/pooling.py similarity index 82% rename from src/sampletones_core/famitracker/sequences/pooling.py rename to src/sampletones_core/formats/famitracker/sequences/pooling.py index fe4842ad..3a5823f2 100644 --- a/src/sampletones_core/famitracker/sequences/pooling.py +++ b/src/sampletones_core/formats/famitracker/sequences/pooling.py @@ -1,9 +1,9 @@ from typing import Dict, List, Sequence, Tuple -from sampletones_core.famitracker.model.instrument import Instrument2A03 -from sampletones_core.famitracker.model.sequence import InstrumentSequence -from sampletones_core.famitracker.sequences.pooled import PooledSequence -from sampletones_core.famitracker.specification.sequences import ( +from sampletones_core.formats.famitracker.model.instrument import Instrument2A03 +from sampletones_core.formats.famitracker.model.sequence import InstrumentSequence +from sampletones_core.formats.famitracker.sequences.pooled import PooledSequence +from sampletones_core.formats.famitracker.specification.sequences import ( MAX_SEQUENCES_PER_TYPE, SequenceKind, ) diff --git a/src/sampletones_core/formats/famitracker/specification/__init__.py b/src/sampletones_core/formats/famitracker/specification/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/sampletones_core/famitracker/specification/blocks.py b/src/sampletones_core/formats/famitracker/specification/blocks.py similarity index 100% rename from src/sampletones_core/famitracker/specification/blocks.py rename to src/sampletones_core/formats/famitracker/specification/blocks.py diff --git a/src/sampletones_core/famitracker/specification/channels.py b/src/sampletones_core/formats/famitracker/specification/channels.py similarity index 100% rename from src/sampletones_core/famitracker/specification/channels.py rename to src/sampletones_core/formats/famitracker/specification/channels.py diff --git a/src/sampletones_core/famitracker/specification/file.py b/src/sampletones_core/formats/famitracker/specification/file.py similarity index 100% rename from src/sampletones_core/famitracker/specification/file.py rename to src/sampletones_core/formats/famitracker/specification/file.py diff --git a/src/sampletones_core/famitracker/specification/instruments.py b/src/sampletones_core/formats/famitracker/specification/instruments.py similarity index 65% rename from src/sampletones_core/famitracker/specification/instruments.py rename to src/sampletones_core/formats/famitracker/specification/instruments.py index 4a66037e..353b535d 100644 --- a/src/sampletones_core/famitracker/specification/instruments.py +++ b/src/sampletones_core/formats/famitracker/specification/instruments.py @@ -1,10 +1,12 @@ from typing import Final -from sampletones_core.famitracker.specification.patterns import NOTE_RANGE, OCTAVE_RANGE +from sampletones_core.formats.famitracker.specification.patterns import NOTE_RANGE, OCTAVE_RANGE INSTRUMENT_TYPE_2A03: Final[int] = 1 MAX_INSTRUMENTS: Final[int] = 64 +STANDALONE_INSTRUMENT_INDEX: Final[int] = 0 + DPCM_KEY_ASSIGNMENTS: Final[int] = NOTE_RANGE * OCTAVE_RANGE DPCM_KEY_BYTES: Final[int] = 3 diff --git a/src/sampletones_core/famitracker/specification/parameters.py b/src/sampletones_core/formats/famitracker/specification/parameters.py similarity index 100% rename from src/sampletones_core/famitracker/specification/parameters.py rename to src/sampletones_core/formats/famitracker/specification/parameters.py diff --git a/src/sampletones_core/famitracker/specification/patterns.py b/src/sampletones_core/formats/famitracker/specification/patterns.py similarity index 100% rename from src/sampletones_core/famitracker/specification/patterns.py rename to src/sampletones_core/formats/famitracker/specification/patterns.py diff --git a/src/sampletones_core/famitracker/specification/sequences.py b/src/sampletones_core/formats/famitracker/specification/sequences.py similarity index 100% rename from src/sampletones_core/famitracker/specification/sequences.py rename to src/sampletones_core/formats/famitracker/specification/sequences.py diff --git a/src/sampletones_core/library/data.py b/src/sampletones_core/library/data.py index 13f621a0..73dd6cba 100644 --- a/src/sampletones_core/library/data.py +++ b/src/sampletones_core/library/data.py @@ -144,6 +144,7 @@ def validate_metadata(metadata: Metadata) -> None: ) library_version = metadata.library_data_version + print(compare_versions(library_version, SAMPLETONES_LIBRARY_DATA_VERSION)) if compare_versions(library_version, SAMPLETONES_LIBRARY_DATA_VERSION) != 0: raise IncompatibleLibraryDataVersionError( f"Library data version mismatch: expected " diff --git a/src/sampletones_core/library/filename/fields.py b/src/sampletones_core/library/filename/fields.py index 1ae3acde..5e050b77 100644 --- a/src/sampletones_core/library/filename/fields.py +++ b/src/sampletones_core/library/filename/fields.py @@ -10,6 +10,7 @@ from sampletones_core.paths import EXT_FILE_LIBRARY from sampletones_shared.types.path import Pathlike from sampletones_shared.utils.serialization import HASH_PATTERN +from sampletones_shared.utils.system.paths import get_filename FILENAME_SEPARATOR: Final[str] = "_" @@ -31,7 +32,7 @@ def stem(self) -> str: @property def filename(self) -> str: - return f"{self.stem}{EXT_FILE_LIBRARY}" + return get_filename(self.stem, EXT_FILE_LIBRARY) @classmethod def create(cls, pathlike: Pathlike) -> InstructionsFilenameFields: diff --git a/src/sampletones_core/library/filename/utils.py b/src/sampletones_core/library/filename/utils.py index 7b9e08ec..194cd700 100644 --- a/src/sampletones_core/library/filename/utils.py +++ b/src/sampletones_core/library/filename/utils.py @@ -11,6 +11,7 @@ from sampletones_core.library.key import InstructionLibraryKey from sampletones_core.paths import EXT_FILE_LIBRARY from sampletones_shared.types.path import Pathlike +from sampletones_shared.utils.system.paths import get_filename def create_key_from_filename(filename: Pathlike) -> InstructionLibraryKey: @@ -32,7 +33,7 @@ def create_key_from_filename(filename: Pathlike) -> InstructionLibraryKey: transformation_gamma=transformation_gamma, spectrum_method=spectrum_method, config_hash=config_hash, - filename=f"{filename}{EXT_FILE_LIBRARY}", + filename=get_filename(filename, EXT_FILE_LIBRARY), ) diff --git a/src/sampletones_core/paths.py b/src/sampletones_core/paths.py index 19c3a313..f0564761 100644 --- a/src/sampletones_core/paths.py +++ b/src/sampletones_core/paths.py @@ -28,6 +28,7 @@ EXT_FILE_RECONSTRUCTION: Final[str] = ".stn" EXT_FILE_PROJECT: Final[str] = ".stp" EXT_FILE_MODULE: Final[str] = ".ftm" +EXT_FILE_BITPHASE: Final[str] = ".btp" EXT_FILE_WAVE: Final[str] = ".wav" EXT_FILE_MP3: Final[str] = ".mp3" EXT_FILE_FLAC: Final[str] = ".flac" diff --git a/src/sampletones_core/project/container.py b/src/sampletones_core/project/container.py index af5fdefd..c5186a2c 100644 --- a/src/sampletones_core/project/container.py +++ b/src/sampletones_core/project/container.py @@ -29,6 +29,7 @@ ) from sampletones_shared.types.path import Pathlike from sampletones_shared.utils.serialization import JSON_INDENT +from sampletones_shared.utils.system.paths import get_filename class ProjectContainer: @@ -54,7 +55,8 @@ def save(project: Project, path: Pathlike) -> None: with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as archive: archive.writestr(PROJECT_DOCUMENT_NAME, payload) for reconstruction_id, reconstruction in reconstructions.items(): - name = f"{RECONSTRUCTIONS_DIRECTORY}/{reconstruction_id}{EXT_FILE_RECONSTRUCTION}" + filename = get_filename(reconstruction_id, EXT_FILE_RECONSTRUCTION) + name = f"{RECONSTRUCTIONS_DIRECTORY}/{filename}" archive.writestr(name, reconstruction.serialize()) @staticmethod diff --git a/src/sampletones_core/reconstructions/reconstruction/instructions.py b/src/sampletones_core/reconstructions/reconstruction/instructions.py index 211783f4..be2663e5 100644 --- a/src/sampletones_core/reconstructions/reconstruction/instructions.py +++ b/src/sampletones_core/reconstructions/reconstruction/instructions.py @@ -12,9 +12,17 @@ class InstructionsItem(DataModel): model_config = ConfigDict(arbitrary_types_allowed=True, frozen=True) - generator_name: GeneratorName = Field(..., description="Name of the generator") + generator_name: GeneratorName = Field( + ..., + description="Name of the generator", + ) instructions: List[InstructionData[InstructionUnion]] = Field( - ..., description="List of instruction data for the generator" + ..., + description="List of instruction data for the generator", + ) + initial_pitch: int = Field( + ..., + description="Reference pitch the generator's arpeggio envelope is measured against", ) @classmethod @@ -22,6 +30,7 @@ def create( cls, generator_name: GeneratorName, instructions: List[InstructionUnion], + initial_pitch: int, ) -> InstructionsItem: return InstructionsItem( generator_name=generator_name, @@ -32,4 +41,5 @@ def create( ) for instruction in instructions ], + initial_pitch=initial_pitch, ) diff --git a/src/sampletones_core/reconstructions/reconstruction/reconstruction.py b/src/sampletones_core/reconstructions/reconstruction/reconstruction.py index a7a9b607..cd3d9185 100644 --- a/src/sampletones_core/reconstructions/reconstruction/reconstruction.py +++ b/src/sampletones_core/reconstructions/reconstruction/reconstruction.py @@ -13,6 +13,7 @@ from sampletones_core.constants.enums import GeneratorName from sampletones_core.data import DataModel, Metadata from sampletones_core.exporters import ( + GENERATOR_NAME_TO_EXPORTER_MAP, INSTRUCTION_TO_EXPORTER_MAP, ExporterTypeUnion, ExporterUnion, @@ -47,17 +48,39 @@ class Reconstruction(DataModel): model_config = ConfigDict(arbitrary_types_allowed=True) - metadata: Metadata = Field(default_factory=Metadata.default, description="Reconstruction metadata") - id: str = Field(..., description="Unique identifier for the reconstruction") + metadata: Metadata = Field( + default_factory=Metadata.default, + description="Reconstruction metadata", + ) + id: str = Field( + ..., + description="Unique identifier for the reconstruction", + ) audio_filepath: Optional[Path] = Field( ..., description="Location of the source audio; None marks a reconstruction detached from its local origin", ) - config: Config = Field(..., description="Configuration used for reconstruction", frozen=True) - approximation: np.ndarray = Field(..., description="Audio approximation") - approximations_data: List[ApproximationsItem] = Field(..., description="Approximations per generator") - instructions_data: List[InstructionsItem] = Field(..., description="Instructions per generator") - coefficient: float = Field(..., description="Normalization coefficient used during reconstruction") + config: Config = Field( + ..., + description="Configuration used for reconstruction", + frozen=True, + ) + approximation: np.ndarray = Field( + ..., + description="Audio approximation", + ) + approximations_data: List[ApproximationsItem] = Field( + ..., + description="Approximations per generator", + ) + instructions_data: List[InstructionsItem] = Field( + ..., + description="Instructions per generator", + ) + coefficient: float = Field( + ..., + description="Normalization coefficient used during reconstruction", + ) @cached_property def approximations(self) -> Dict[GeneratorName, np.ndarray]: @@ -70,10 +93,32 @@ def instructions(self) -> Dict[GeneratorName, List[InstructionUnion]]: for item in self.instructions_data } + @cached_property + def initial_pitches(self) -> Dict[GeneratorName, int]: + """The reference pitch each generator's arpeggio envelope is measured against.""" + return {item.generator_name: item.initial_pitch for item in self.instructions_data} + @staticmethod def _get_exporter_class(instruction: InstructionUnion) -> ExporterTypeUnion: return INSTRUCTION_TO_EXPORTER_MAP[type(instruction)] + @classmethod + def _derive_initial_pitch( + cls, + generator_name: GeneratorName, + instructions: List[InstructionUnion], + ) -> int: + """Chooses the reference pitch a channel's arpeggio envelope is measured against. + + The instruction type selects the exporter, matching how `export` resolves one. A + channel carrying no instructions takes the exporter its generator name pairs with, + which reports that exporter's resting reference. + """ + exporter_class = ( + cls._get_exporter_class(instructions[0]) if instructions else GENERATOR_NAME_TO_EXPORTER_MAP[generator_name] + ) + return exporter_class.derive_initial_pitch(instructions) # type: ignore[arg-type] + @classmethod def create( cls, @@ -92,10 +137,12 @@ def create( instructions_data: List[InstructionsItem] = [] for generator_name, instructions_list in instructions.items(): + channel_instructions = list(instructions_list) instructions_data.append( InstructionsItem.create( generator_name=generator_name, - instructions=list(instructions_list), + instructions=channel_instructions, + initial_pitch=cls._derive_initial_pitch(generator_name, channel_instructions), ) ) @@ -138,7 +185,13 @@ def update_generator_data( generator_name: GeneratorName, instructions: List[InstructionUnion], partial_approximation: np.ndarray, + initial_pitch: int, ) -> None: + """Replaces one generator's instructions, audio, and reference pitch. + + The reference pitch travels with the instructions it produced, so a later export + measures the arpeggio against the same base the edit was made from. + """ partial_approximation = np.trim_zeros(partial_approximation, trim="b") max_length = max( len(partial_approximation), @@ -152,7 +205,11 @@ def update_generator_data( self.approximations_data = self._build_approximations_data(rendered, max_length) self.instructions_data = [ ( - InstructionsItem.create(generator_name=generator_name, instructions=instructions) + InstructionsItem.create( + generator_name=generator_name, + instructions=instructions, + initial_pitch=initial_pitch, + ) if item.generator_name == generator_name else item ) @@ -260,6 +317,7 @@ def _invalidate_derived_caches(reconstruction: Reconstruction) -> None: """Drops the memoized per-generator views so they recompute from their backing data.""" reconstruction.__dict__.pop("approximations", None) reconstruction.__dict__.pop("instructions", None) + reconstruction.__dict__.pop("initial_pitches", None) @classmethod def load(cls, path: Pathlike, fast: bool = True) -> Reconstruction: @@ -313,7 +371,11 @@ def validate_metadata(metadata: Metadata) -> None: actual_version=reconstruction_version, ) - def _validate_instructions(self, exporter: ExporterUnion, instructions: List[InstructionUnion]) -> None: + def _validate_instructions( + self, + exporter: ExporterUnion, + instructions: List[InstructionUnion], + ) -> None: first_instruction: InstructionUnion = instructions[0] exporter_class = self._get_exporter_class(instructions[0]) exporter_instruction_type = exporter.get_instruction_type() @@ -336,17 +398,28 @@ def export(self) -> Dict[GeneratorName, Features]: exporter_class = self._get_exporter_class(instructions[0]) exporter: ExporterUnion = exporter_class() self._validate_instructions(exporter, instructions) - feature: Features = exporter.to_features(instructions) # type: ignore[arg-type] + feature: Features = exporter.to_features( + instructions, # type: ignore[arg-type] + self.initial_pitches[name], + ) features[name] = feature return features @field_serializer("approximation") - def _serialize_approximation(self, approximation: np.ndarray, _info: Any) -> SerializedData: + def _serialize_approximation( + self, + approximation: np.ndarray, + _info: Any, + ) -> SerializedData: return serialize_array(approximation) @field_serializer("audio_filepath") - def _serialize_audio_filepath(self, audio_filepath: Optional[Path], _info: Any) -> Optional[str]: + def _serialize_audio_filepath( + self, + audio_filepath: Optional[Path], + _info: Any, + ) -> Optional[str]: if audio_filepath is None: return None diff --git a/src/sampletones_core/trackers/__init__.py b/src/sampletones_core/trackers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/sampletones_core/trackers/artifact.py b/src/sampletones_core/trackers/artifact.py new file mode 100644 index 00000000..9d99a04b --- /dev/null +++ b/src/sampletones_core/trackers/artifact.py @@ -0,0 +1,19 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Optional, Tuple + +from sampletones_core.exporters.truncation import EnvelopeTruncation + + +@dataclass(frozen=True) +class ExportArtifact: + """What one export run left on disk. + + Attributes: + paths: Every file the run wrote, in write order. + truncation: What the target format's item limit left out, and ``None`` when + every instrument carries its whole envelope. + """ + + paths: Tuple[Path, ...] + truncation: Optional[EnvelopeTruncation] diff --git a/src/sampletones_core/trackers/backend.py b/src/sampletones_core/trackers/backend.py new file mode 100644 index 00000000..6f3a6d77 --- /dev/null +++ b/src/sampletones_core/trackers/backend.py @@ -0,0 +1,96 @@ +from pathlib import Path +from typing import FrozenSet, Protocol + +from sampletones_core.trackers.artifact import ExportArtifact +from sampletones_core.trackers.format import TrackerFormat +from sampletones_core.trackers.request import ( + InstrumentExport, + ProjectExport, + SampleExport, +) +from sampletones_core.trackers.scope import ExportScope + + +class TrackerBackend(Protocol): + """Writes the application's work in the file format one tracker reads. + + A backend owns both the byte layout and the shape each :class:`ExportScope` takes on + disk, so a format that gathers a whole reconstruction into one document writes one + where another writes a file per instrument. Every scope is written to a file path the + caller chooses, and :meth:`extension` names the extension it carries. + """ + + @property + def tracker_format(self) -> TrackerFormat: + """The format this backend writes.""" + + @property + def supported_scopes(self) -> FrozenSet[ExportScope]: + """The scopes this format can express.""" + + def extension(self, scope: ExportScope) -> str: + """The extension the files of ``scope`` carry, leading dot included. + + Args: + scope: The scope about to be exported. + + Returns: + str: The extension of each file the run writes. + """ + + def write_instrument( + self, + destination: Path, + request: InstrumentExport, + ) -> ExportArtifact: + """Writes one generator slice. + + Args: + destination: The file to write. + request: The slice to write. + + Returns: + ExportArtifact: The paths written and what the format's limits left out. + + Raises: + OSError: If the destination cannot be written. + """ + + def write_sample( + self, + destination: Path, + request: SampleExport, + ) -> ExportArtifact: + """Writes every generator slice of one reconstruction. + + Args: + destination: The file this scope is written to. A format that keeps one + instrument per file writes its slices beside it, each named after the + instrument it carries. + request: The reconstruction's slices. + + Returns: + ExportArtifact: The paths written and what the format's limits left out. + + Raises: + OSError: If the destination cannot be written. + """ + + def write_project( + self, + destination: Path, + request: ProjectExport, + ) -> ExportArtifact: + """Writes a whole composition. + + Args: + destination: The file to write. + request: The project to write. + + Returns: + ExportArtifact: The paths written and what the format's limits left out. + + Raises: + OSError: If the destination cannot be written. + ValueError: If the project holds more than the format has room for. + """ diff --git a/src/sampletones_core/trackers/extensions.py b/src/sampletones_core/trackers/extensions.py new file mode 100644 index 00000000..7333ea06 --- /dev/null +++ b/src/sampletones_core/trackers/extensions.py @@ -0,0 +1,33 @@ +from typing import Mapping, Optional + +from sampletones_core.trackers.backend import TrackerBackend +from sampletones_core.trackers.format import TrackerFormat +from sampletones_core.trackers.scope import ExportScope + + +def format_for_extension( + backends: Mapping[TrackerFormat, TrackerBackend], + scope: ExportScope, + extension: str, +) -> Optional[TrackerFormat]: + """The format whose ``scope`` files carry ``extension``. + + The destination the user names decides which tracker the export is written for, so the + extension it ends in resolves to a format here. Case folds, letting a destination typed + in capitals reach the same backend. + + Args: + backends: Every backend the application writes through, keyed by its format. + scope: The scope about to be exported. + extension: The extension the chosen destination carries, leading dot included. + + Returns: + Optional[TrackerFormat]: The format claiming ``extension``, or ``None`` when no + format able to express ``scope`` writes it. + """ + wanted = extension.casefold() + for tracker_format, backend in backends.items(): + if scope in backend.supported_scopes and backend.extension(scope).casefold() == wanted: + return tracker_format + + return None diff --git a/src/sampletones_core/trackers/format.py b/src/sampletones_core/trackers/format.py new file mode 100644 index 00000000..625f55e5 --- /dev/null +++ b/src/sampletones_core/trackers/format.py @@ -0,0 +1,9 @@ +from enum import StrEnum + + +class TrackerFormat(StrEnum): + """A file format one tracker reads, and the backend that writes it.""" + + FAMITRACKER = "famitracker" + BITPHASE = "bitphase" + BITPHASE_PRESET = "bitphase_preset" diff --git a/src/sampletones_core/trackers/implementation/__init__.py b/src/sampletones_core/trackers/implementation/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/sampletones_core/trackers/implementation/bitphase.py b/src/sampletones_core/trackers/implementation/bitphase.py new file mode 100644 index 00000000..83e40cf5 --- /dev/null +++ b/src/sampletones_core/trackers/implementation/bitphase.py @@ -0,0 +1,121 @@ +from pathlib import Path +from typing import FrozenSet, List + +from sampletones_core.formats.bitphase.btp import write_btp +from sampletones_core.formats.bitphase.builder import ( + instrument_to_bitphase, + project_to_bitphase, + sample_to_bitphase, +) +from sampletones_core.formats.bitphase.preset import instrument_to_preset, write_preset +from sampletones_core.paths import EXT_FILE_BITPHASE, EXT_FILE_JSON +from sampletones_core.trackers.artifact import ExportArtifact +from sampletones_core.trackers.format import TrackerFormat +from sampletones_core.trackers.request import InstrumentExport, ProjectExport, SampleExport +from sampletones_core.trackers.scope import ExportScope +from sampletones_shared.utils.system.paths import get_filename + +DOCUMENT_SCOPES: FrozenSet[ExportScope] = frozenset(ExportScope) +PRESET_SCOPES: FrozenSet[ExportScope] = frozenset({ExportScope.INSTRUMENT, ExportScope.SAMPLE}) + +WHOLE_ENVELOPE: None = None + + +class BitphaseBackend: + """Writes Bitphase's ``.btp`` documents. + + A ``.btp`` holds a whole document, so every scope lands in one file: an instrument + and a reconstruction each become a playable document whose pattern triggers the + instruments it carries. Bitphase stores instrument and table rows without a length + limit, so every envelope crosses over whole. + """ + + @property + def tracker_format(self) -> TrackerFormat: + return TrackerFormat.BITPHASE + + @property + def supported_scopes(self) -> FrozenSet[ExportScope]: + return DOCUMENT_SCOPES + + def extension(self, scope: ExportScope) -> str: + return EXT_FILE_BITPHASE + + def write_instrument( + self, + destination: Path, + request: InstrumentExport, + ) -> ExportArtifact: + write_btp(destination, instrument_to_bitphase(request)) + return ExportArtifact(paths=(destination,), truncation=WHOLE_ENVELOPE) + + def write_sample( + self, + destination: Path, + request: SampleExport, + ) -> ExportArtifact: + write_btp(destination, sample_to_bitphase(request)) + return ExportArtifact(paths=(destination,), truncation=WHOLE_ENVELOPE) + + def write_project( + self, + destination: Path, + request: ProjectExport, + ) -> ExportArtifact: + write_btp(destination, project_to_bitphase(request.project)) + return ExportArtifact(paths=(destination,), truncation=WHOLE_ENVELOPE) + + +class BitphasePresetBackend: + """Writes the single-instrument ``.json`` files Bitphase's instruments panel loads. + + The panel reads one instrument per file into the slot the user has selected, so a + whole reconstruction lands as a set of them beside the chosen destination, one file + per generator slice named after the instrument. A preset carries rows alone, so its + pitch contour rides in each row's tone offset. + """ + + @property + def tracker_format(self) -> TrackerFormat: + return TrackerFormat.BITPHASE_PRESET + + @property + def supported_scopes(self) -> FrozenSet[ExportScope]: + return PRESET_SCOPES + + def extension(self, scope: ExportScope) -> str: + return EXT_FILE_JSON + + def write_instrument( + self, + destination: Path, + request: InstrumentExport, + ) -> ExportArtifact: + write_preset(destination, instrument_to_preset(request)) + return ExportArtifact(paths=(destination,), truncation=WHOLE_ENVELOPE) + + def write_sample( + self, + destination: Path, + request: SampleExport, + ) -> ExportArtifact: + destination.parent.mkdir(parents=True, exist_ok=True) + + paths: List[Path] = [] + for instrument in request.instruments: + filepath = destination.with_name(get_filename(instrument.name, EXT_FILE_JSON)) + paths.extend(self.write_instrument(filepath, instrument).paths) + + return ExportArtifact(paths=tuple(paths), truncation=WHOLE_ENVELOPE) + + def write_project( + self, + destination: Path, + request: ProjectExport, + ) -> ExportArtifact: + """Reports that a preset holds one instrument. + + Raises: + ValueError: Always, since a preset file carries a single instrument. + """ + raise ValueError("A Bitphase instrument preset holds one instrument, not a whole project") diff --git a/src/sampletones_core/trackers/implementation/famitracker.py b/src/sampletones_core/trackers/implementation/famitracker.py new file mode 100644 index 00000000..cebbd102 --- /dev/null +++ b/src/sampletones_core/trackers/implementation/famitracker.py @@ -0,0 +1,99 @@ +from pathlib import Path +from typing import FrozenSet, List, Optional + +from sampletones_core.exporters.truncation import EnvelopeTruncation +from sampletones_core.formats.famitracker.export import write_ftm +from sampletones_core.formats.famitracker.instrument import write_fti +from sampletones_core.formats.famitracker.model.instrument import Instrument2A03 +from sampletones_core.formats.famitracker.sequences.features import features_to_instrument_sequences +from sampletones_core.formats.famitracker.specification.instruments import STANDALONE_INSTRUMENT_INDEX +from sampletones_core.formats.famitracker.specification.sequences import MAX_SEQUENCE_ITEMS +from sampletones_core.paths import EXT_FILE_INSTRUMENT, EXT_FILE_MODULE +from sampletones_core.trackers.artifact import ExportArtifact +from sampletones_core.trackers.format import TrackerFormat +from sampletones_core.trackers.request import ( + InstrumentExport, + ProjectExport, + SampleExport, +) +from sampletones_core.trackers.scope import ExportScope +from sampletones_shared.utils.system.paths import get_filename + +SUPPORTED_SCOPES: FrozenSet[ExportScope] = frozenset(ExportScope) + + +class FamiTrackerBackend: + """Writes FamiTracker's ``.fti`` instruments and ``.ftm`` modules. + + FamiTracker reads one instrument per ``.fti`` file, so a whole reconstruction lands + as a set of them beside the chosen destination, one file per generator slice named + after the instrument. + """ + + @property + def tracker_format(self) -> TrackerFormat: + return TrackerFormat.FAMITRACKER + + @property + def supported_scopes(self) -> FrozenSet[ExportScope]: + return SUPPORTED_SCOPES + + def extension(self, scope: ExportScope) -> str: + return EXT_FILE_MODULE if scope == ExportScope.PROJECT else EXT_FILE_INSTRUMENT + + def write_instrument( + self, + destination: Path, + request: InstrumentExport, + ) -> ExportArtifact: + features = request.features + sequences = features_to_instrument_sequences( + volume=features.volume, + arpeggio=features.arpeggio, + pitch=features.pitch, + hi_pitch=features.hi_pitch, + duty_cycle=features.duty_cycle, + loop=request.loop, + ) + instrument = Instrument2A03( + index=STANDALONE_INSTRUMENT_INDEX, + name=request.name, + sequences=sequences, + ) + write_fti(destination, instrument) + + return ExportArtifact( + paths=(destination,), + truncation=EnvelopeTruncation.measure( + features.frame_count, + MAX_SEQUENCE_ITEMS, + ), + ) + + def write_sample( + self, + destination: Path, + request: SampleExport, + ) -> ExportArtifact: + destination.parent.mkdir(parents=True, exist_ok=True) + + paths: List[Path] = [] + truncations: List[Optional[EnvelopeTruncation]] = [] + for instrument in request.instruments: + filepath = destination.with_name(get_filename(instrument.name, EXT_FILE_INSTRUMENT)) + artifact = self.write_instrument(filepath, instrument) + paths.extend(artifact.paths) + truncations.append(artifact.truncation) + + return ExportArtifact( + paths=tuple(paths), + truncation=EnvelopeTruncation.summarize(truncations), + ) + + def write_project( + self, + destination: Path, + request: ProjectExport, + ) -> ExportArtifact: + write_ftm(destination, request.project) + return ExportArtifact(paths=(destination,), truncation=None) diff --git a/src/sampletones_core/trackers/registry.py b/src/sampletones_core/trackers/registry.py new file mode 100644 index 00000000..c979a926 --- /dev/null +++ b/src/sampletones_core/trackers/registry.py @@ -0,0 +1,23 @@ +from typing import Dict + +from sampletones_core.trackers.backend import TrackerBackend +from sampletones_core.trackers.format import TrackerFormat +from sampletones_core.trackers.implementation.bitphase import BitphaseBackend, BitphasePresetBackend +from sampletones_core.trackers.implementation.famitracker import FamiTrackerBackend + + +def build_tracker_backends() -> Dict[TrackerFormat, TrackerBackend]: + """Builds one backend per tracker format the application can write. + + The composition root calls this once and hands the result to the components that + offer a format choice, so a new format reaches the whole application by joining + this mapping. + + Returns: + Dict[TrackerFormat, TrackerBackend]: Every backend, keyed by the format it writes. + """ + return { + TrackerFormat.FAMITRACKER: FamiTrackerBackend(), + TrackerFormat.BITPHASE: BitphaseBackend(), + TrackerFormat.BITPHASE_PRESET: BitphasePresetBackend(), + } diff --git a/src/sampletones_core/trackers/request.py b/src/sampletones_core/trackers/request.py new file mode 100644 index 00000000..6b75932c --- /dev/null +++ b/src/sampletones_core/trackers/request.py @@ -0,0 +1,51 @@ +from dataclasses import dataclass +from typing import Tuple + +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.exporters.feature import Features +from sampletones_core.project.project import Project + + +@dataclass(frozen=True) +class InstrumentExport: + """One generator slice of a reconstruction, ready for a backend to write. + + Attributes: + name: Name the written instrument carries. + generator: The NES channel the slice was reconstructed for. + features: The per-dimension envelopes describing the slice. + loop: Whether the instrument repeats its envelopes while its note is held. + nes_frequency: Rate in Hz the envelopes advance at, one item per tick. + """ + + name: str + generator: GeneratorName + features: Features + loop: bool + nes_frequency: int + + +@dataclass(frozen=True) +class SampleExport: + """Every generator slice of one reconstruction. + + Attributes: + name: Name of the reconstruction the slices came from. + instruments: One entry per channel the reconstruction covers. + nes_frequency: Rate in Hz the envelopes advance at, one item per tick. + """ + + name: str + instruments: Tuple[InstrumentExport, ...] + nes_frequency: int + + +@dataclass(frozen=True) +class ProjectExport: + """A whole composition — its samples and the song that arranges them. + + Attributes: + project: The project to write. + """ + + project: Project diff --git a/src/sampletones_core/trackers/scope.py b/src/sampletones_core/trackers/scope.py new file mode 100644 index 00000000..c7bd3afe --- /dev/null +++ b/src/sampletones_core/trackers/scope.py @@ -0,0 +1,13 @@ +from enum import StrEnum + + +class ExportScope(StrEnum): + """How much of the application's work one export run carries. + + A backend decides how each scope materialises on disk, so a format that reads a + whole reconstruction from a single file is free to write one. + """ + + INSTRUMENT = "instrument" + SAMPLE = "sample" + PROJECT = "project" diff --git a/src/sampletones_core/utils/display.py b/src/sampletones_core/utils/display.py index 4622c804..c9a3b45b 100644 --- a/src/sampletones_core/utils/display.py +++ b/src/sampletones_core/utils/display.py @@ -4,9 +4,13 @@ from sampletones_core.project.instruments.note_off import NoteOff from sampletones_core.project.instruments.sample import Sample from sampletones_core.structures import IdentifiedCollection +from sampletones_shared.constants.symbols import MINUS, PLUS DEFAULT_DISPLAY_LENGTH: Final[int] = 2 + +BLANK: Final[str] = "." NOTE_OFF: Final[str] = "~~" +NOTE_BLANK: Final[str] = "..." def display_value( @@ -16,7 +20,7 @@ def display_value( hexadecimal: bool = True, ) -> str: if value is None: - return "." * length + return BLANK * length if hexadecimal: return f"{value:0{length}X}" @@ -67,8 +71,8 @@ def display_volume(value: Optional[int]) -> str: def display_transpose(value: Optional[int]) -> str: if value is None or value == 0: - return "..." + return NOTE_BLANK - sign = "+" if value > 0 else "-" + sign = PLUS if value > 0 else MINUS abs_value = abs(value) return f"{sign}{abs_value:02X}" diff --git a/src/sampletones_shared/application.py b/src/sampletones_shared/application.py index 67559978..cf5222e6 100644 --- a/src/sampletones_shared/application.py +++ b/src/sampletones_shared/application.py @@ -7,7 +7,7 @@ SAMPLETONES_VERSION: Final[str] = metadata.version(SAMPLETONES_PACKAGE_NAME) SAMPLETONES_LIBRARY_DATA_VERSION: Final[str] = "2.0" -SAMPLETONES_RECONSTRUCTION_DATA_VERSION: Final[str] = "2.0" +SAMPLETONES_RECONSTRUCTION_DATA_VERSION: Final[str] = "2.1" SAMPLETONES_PROJECT_DATA_VERSION: Final[str] = "1.0" SAMPLETONES_NAME_VERSION: Final[str] = f"{SAMPLETONES_NAME} v{SAMPLETONES_VERSION}" diff --git a/src/sampletones_shared/constants/project.py b/src/sampletones_shared/constants/project.py index 0989f749..db32c5b5 100644 --- a/src/sampletones_shared/constants/project.py +++ b/src/sampletones_shared/constants/project.py @@ -9,7 +9,7 @@ DEFAULT_PROJECT_AUTHOR: Final[str] = "" DEFAULT_PROJECT_COMMENT: Final[str] = "" DEFAULT_PROJECT_FILENAME: Final[str] = "Untitled.stp" -DEFAULT_MODULE_FILENAME: Final[str] = "Untitled.ftm" +DEFAULT_EXPORT_NAME: Final[str] = "Untitled" # Project info length limits MAX_PROJECT_TITLE_LENGTH: Final[int] = 64 diff --git a/src/sampletones_shared/deployment/version.py b/src/sampletones_shared/deployment/version.py index d4e10d59..cc420a03 100644 --- a/src/sampletones_shared/deployment/version.py +++ b/src/sampletones_shared/deployment/version.py @@ -1,56 +1,107 @@ -from typing import List +from typing import Dict, Iterable, Self, Tuple, TypeAlias, Union +from pydantic import BaseModel, Field, computed_field, model_validator -def _split_version(version: str) -> List[int]: +RawVersion: TypeAlias = Union[str, Iterable[int]] + + +class Version(BaseModel, frozen=True): + major: int = Field(ge=0) + minor: int = Field(ge=0) + patch: int = Field(ge=0) + + def __str__(self) -> str: + return f"{self.major}.{self.minor}.{self.patch}" + + def __repr__(self) -> str: + return str(self) + + def __le__(self, other: Self) -> bool: + return self.tuple <= other.tuple + + def __getitem__(self, key: int) -> int: + return self.tuple[key] + + @computed_field # type: ignore[prop-decorator] + @property + def tuple(self) -> Tuple[int, int, int]: + return self.major, self.minor, self.patch + + @model_validator(mode="before") + @classmethod + def parse_string(cls, value: RawVersion) -> Dict[str, int]: + if not isinstance(value, str) and not isinstance(value, tuple): + raise TypeError(f"Expected a tuple or a string, got {type(value)}") + + if isinstance(value, str): + parts = tuple(filter(bool, value.split("."))) + else: + parts = tuple(value) + + if not 1 <= len(parts) <= 3: + raise ValueError("Version must have 1-3 components") + + try: + numbers = list(map(int, parts)) + except ValueError as exception: + raise ValueError("Version components must be integers") from exception + + numbers.extend([0] * (3 - len(numbers))) + + return { + "major": numbers[0], + "minor": numbers[1], + "patch": numbers[2], + } + + +def _split_version(version: RawVersion) -> Version: """ Splits a dotted version string into its integer components. Args: - version (str): A dotted version string such as ``1.4.0``. + version (RawVersion): A dotted version string such as ``1.4.0``, + or a tuple of integers such as ``(1, 4, 0)``. Returns: - List[int]: The version's numeric components in order. + Version: The version object. Raises: - SystemError: If any component is not an integer. + SystemError: If input raw version object is not valid. """ try: - return list(map(int, version.split("."))) + return Version.model_validate(version) except ValueError as exception: raise SystemError(f"Invalid version format: {exception}") from exception -def compare_versions(version1: str, version2: str) -> int: +def compare_versions( + version1: Union[Version, RawVersion], + version2: Union[Version, RawVersion], +) -> int: """ Compares two dotted version strings numerically. Shorter versions are zero-padded, so ``1.2`` and ``1.2.0`` compare equal. Args: - version1 (str): First dotted version string (e.g. ``1.4.0``). - version2 (str): Second dotted version string. + version1 (RawVersion): First dotted version string or a version integer tuple. + version2 (RawVersion): Second dotted version string or a version integer tuple. Returns: int: ``-1`` if version1 precedes version2, ``1`` if it follows, ``0`` if they are equal. Raises: - SystemError: If either string holds a non-integer component. + SystemError: If versions raw objects are not valid. """ - v1_parts = _split_version(version1) - v2_parts = _split_version(version2) - - length_difference = len(v1_parts) - len(v2_parts) - if length_difference > 0: - v2_parts.extend([0] * length_difference) - - elif length_difference < 0: - v1_parts.extend([0] * -length_difference) + if not isinstance(version1, Version): + version1 = _split_version(version1) - for part1, part2 in zip(v1_parts, v2_parts): - if part1 < part2: - return -1 + if not isinstance(version2, Version): + version2 = _split_version(version2) - if part1 > part2: - return 1 + if version1 == version2: + return 0 - return 0 + difference = int(version1 >= version2) + return 2 * difference - 1 diff --git a/src/sampletones_shared/utils/arrays.py b/src/sampletones_shared/utils/arrays.py index 5e7cc17e..250b69c5 100644 --- a/src/sampletones_shared/utils/arrays.py +++ b/src/sampletones_shared/utils/arrays.py @@ -345,6 +345,56 @@ def trim(array: Array) -> Array: return module.concatenate([array[: last_end + 1], [last_value]]) +def hold( + array: Array, + index: int, + *, + default: Numeric, +) -> Numeric: + """ + Reads an envelope at an index, holding its final value past its end. + + An envelope describes the frames it covers and sustains its last value over every + frame beyond them, which is how a dimension trimmed shorter than its sequence keeps + describing the whole of it. An empty envelope describes no frame, so it reads as the + given default. + + Args: + array: The 1-dimensional envelope to read. + index: The frame position to read, counted from the envelope's start. + default: The value an empty envelope reads as. + + Returns: + The value at `index`, the final value once `index` reaches the envelope's end, + or `default` for an empty envelope. + + Raises: + TypeError: If array is not an Array. + ValueError: If array is not 1-dimensional, or if index is negative. + + Examples: + >>> int(hold(np.array([12, 0]), 0, default=0)) + 12 + >>> int(hold(np.array([12, 0]), 5, default=0)) + 0 + >>> hold(np.array([]), 3, default=7) + 7 + """ + if not isinstance(array, ArrayClasses): + raise TypeError(f"Expected array to be Array, got {type(array)}") + + if array.ndim != 1: + raise ValueError("Array must be 1-dimensional") + + if index < 0: + raise ValueError(f"Index must be at least 0, got {index}") + + if not array.size: + return default + + return array[min(index, len(array) - 1)] + + def interpolate_segment( array: Array, start_index: int, diff --git a/src/sampletones_shared/utils/system/paths.py b/src/sampletones_shared/utils/system/paths.py index b37a0e6c..30f19867 100644 --- a/src/sampletones_shared/utils/system/paths.py +++ b/src/sampletones_shared/utils/system/paths.py @@ -49,6 +49,26 @@ def to_path(path: GeneralPathlike) -> Path: return Path(path) +def get_filename(name: str, extension: str) -> str: + """ + Composes a file name from the name a thing is known by and its extension. + + Every place that names a file composes it here — an exported instrument, a saved + library, a corpus item, a destination a dialog suggests — so a name and the file + holding it stay in step. The name is carried verbatim, so one holding dots keeps + them (``Kick v1.2`` becomes ``Kick v1.2.fti``). :func:`ensure_suffix` covers a path + that may already end with the extension. + + Args: + name (str): The name the file is known by, without its extension. + extension (str): The extension the file carries, leading dot included. + + Returns: + str: The file name, of the form ``name.extension``. + """ + return f"{name}{extension}" + + def ensure_suffix(path: Path, suffix: str) -> Path: """ Returns the path with ``suffix`` appended when its name lacks that ending. diff --git a/tests/integration/bitphase/__init__.py b/tests/integration/bitphase/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/integration/bitphase/conftest.py b/tests/integration/bitphase/conftest.py new file mode 100644 index 00000000..b55d74ac --- /dev/null +++ b/tests/integration/bitphase/conftest.py @@ -0,0 +1,19 @@ +from pathlib import Path +from typing import Optional + +import pytest + +from tests.integration.output import resolve_output_directory, resolve_output_path +from tests.integration.paths import BTP_OUTPUT_ENV, DOCUMENT_FILENAME + + +@pytest.fixture(scope="session") +def btp_output_dir() -> Optional[Path]: + """The persistent output directory ``SAMPLETONES_BTP_OUTPUT_DIR`` names.""" + return resolve_output_directory(BTP_OUTPUT_ENV) + + +@pytest.fixture +def document_path(btp_output_dir: Optional[Path], tmp_path: Path) -> Path: + """Where a produced ``.btp`` is written.""" + return resolve_output_path(btp_output_dir, tmp_path, DOCUMENT_FILENAME) diff --git a/tests/integration/bitphase/test_btp_pipeline.py b/tests/integration/bitphase/test_btp_pipeline.py new file mode 100644 index 00000000..9829d27c --- /dev/null +++ b/tests/integration/bitphase/test_btp_pipeline.py @@ -0,0 +1,223 @@ +from pathlib import Path +from typing import Final, List + +import pytest + +from sampletones_core.formats.bitphase.btp import write_btp +from sampletones_core.formats.bitphase.builder import project_to_bitphase +from sampletones_core.formats.bitphase.specification.channels import CHANNEL_COUNT, CHANNEL_LABELS, ChannelIndex +from sampletones_core.formats.bitphase.specification.chip import ( + CHIP_TYPE_NES, + CPU_FREQUENCIES, + MAX_TUNING_PERIOD, + MIN_TUNING_PERIOD, + TUNING_TABLE_LENGTH, + ChipVariant, +) +from sampletones_core.formats.bitphase.specification.instruments import ( + MAX_PULSE_WIDTH, + MAX_VOLUME_OR_RATE, + MIN_PULSE_WIDTH, + MIN_VOLUME_OR_RATE, + SUSTAINED_SOUND_LENGTH, +) +from sampletones_core.formats.bitphase.specification.patterns import ( + FIRST_OCTAVE, + FULL_VOLUME, + MAX_NOTE_INDEX, + MIN_NOTE_INDEX, + NO_INSTRUMENT_CHANGE, + NOTE_RANGE, + TABLE_COLUMN_OFFSET, + NoteName, +) +from sampletones_core.project.project import Project +from tests.suite.bitphase import LoadedNote, LoadedProject, LoadedRow, parse_btp + +EXPECTED_INSTRUMENT_COUNT: Final[int] = 5 +PLAYED_CHANNELS: Final[List[int]] = [ + int(ChannelIndex.SQUARE1), + int(ChannelIndex.SQUARE2), + int(ChannelIndex.TRIANGLE), + int(ChannelIndex.NOISE), +] + + +def every_row(document: LoadedProject) -> List[LoadedRow]: + """Every tracker line the document holds, across its patterns and their channels.""" + return [row for pattern in document.songs[0].patterns for channel in pattern.channels for row in channel.rows] + + +def note_index(note: LoadedNote) -> int: + """The tuning-table index Bitphase's pattern processor reads back from a note cell.""" + return note.name - int(NoteName.C) + (note.octave - FIRST_OCTAVE) * NOTE_RANGE + + +@pytest.fixture +def document(integration_project: Project, document_path: Path) -> LoadedProject: + write_btp(document_path, project_to_bitphase(integration_project)) + return parse_btp(document_path.read_bytes(), list(CHANNEL_LABELS)) + + +class TestBtpPipeline: + """End-to-end: synthesized + reconstructed samples -> Project -> `.btp` -> load.""" + + def test_writes_a_loadable_document(self, integration_project: Project, document_path: Path) -> None: + write_btp(document_path, project_to_bitphase(integration_project)) + assert document_path.exists() + assert parse_btp(document_path.read_bytes(), list(CHANNEL_LABELS)).songs + + def test_the_document_carries_the_project_metadata( + self, + document: LoadedProject, + integration_project: Project, + ) -> None: + assert document.name == integration_project.info.title + assert document.author == integration_project.info.author + + def test_instrument_count_covers_every_slice(self, document: LoadedProject) -> None: + assert len(document.instruments) == EXPECTED_INSTRUMENT_COUNT + + def test_every_instrument_carries_a_table(self, document: LoadedProject) -> None: + assert len(document.tables) == len(document.instruments) + + def test_the_order_covers_the_song(self, document: LoadedProject, integration_project: Project) -> None: + assert document.pattern_order == list(range(len(integration_project.song.order))) + + def test_the_order_names_patterns_the_song_holds(self, document: LoadedProject) -> None: + held = {pattern.id for pattern in document.songs[0].patterns} + assert set(document.pattern_order) <= held + + def test_patterns_cover_the_played_channels(self, document: LoadedProject) -> None: + triggered = { + index + for pattern in document.songs[0].patterns + for index, channel in enumerate(pattern.channels) + if any(row.instrument != NO_INSTRUMENT_CHANGE for row in channel.rows) + } + assert triggered == set(PLAYED_CHANNELS) + + def test_the_document_carries_audible_volume(self, document: LoadedProject) -> None: + assert any(row.volume_or_rate > 0 for instrument in document.instruments for row in instrument.rows) + + +class TestTheLoaderReadsWhatWasWritten: + """Bitphase reconstructs a project field by field, falling back to a default for each + one it misses, so a field left out of the document reaches playback as that default. + Reading the file back through the same fallbacks is the contract with the tracker. + """ + + def test_the_song_names_the_chip_it_drives(self, document: LoadedProject) -> None: + assert document.songs[0].chip_type == CHIP_TYPE_NES + + def test_every_instrument_names_the_chip_whose_rows_it_holds(self, document: LoadedProject) -> None: + assert {instrument.chip_type for instrument in document.instruments} == {CHIP_TYPE_NES} + + def test_the_song_carries_the_clock_its_tuning_was_built_from(self, document: LoadedProject) -> None: + song = document.songs[0] + assert song.chip_variant == ChipVariant.NTSC + assert song.chip_frequency == CPU_FREQUENCIES[ChipVariant.NTSC] + + def test_the_song_carries_the_speed_and_tick_rate( + self, + document: LoadedProject, + integration_project: Project, + ) -> None: + song = document.songs[0] + assert song.initial_speed == integration_project.settings.speed + assert song.interrupt_frequency == integration_project.settings.nes_frequency + + def test_the_tuning_table_covers_every_note_index(self, document: LoadedProject) -> None: + table = document.songs[0].tuning_table + assert len(table) == TUNING_TABLE_LENGTH + assert all(MIN_TUNING_PERIOD <= period <= MAX_TUNING_PERIOD for period in table) + + def test_every_pattern_spans_the_chip_channels(self, document: LoadedProject) -> None: + assert all(len(pattern.channels) == CHANNEL_COUNT for pattern in document.songs[0].patterns) + + def test_every_channel_fills_its_pattern(self, document: LoadedProject) -> None: + assert all( + len(channel.rows) == pattern.length + for pattern in document.songs[0].patterns + for channel in pattern.channels + ) + + def test_each_channel_is_labelled_as_its_position_names_it(self, document: LoadedProject) -> None: + pattern = document.songs[0].patterns[0] + assert [channel.label for channel in pattern.channels] == list(CHANNEL_LABELS) + + +class TestTheTriggersReachTheirVoices: + """A trigger reaches playback through three columns at once — the instrument that + shapes the note, the table that moves it, and the note itself — so a document whose + columns disagree plays a different voice than the project arranged. + """ + + @pytest.fixture(name="triggers") + def triggers_fixture(self, document: LoadedProject) -> List[LoadedRow]: + return [row for row in every_row(document) if row.instrument != NO_INSTRUMENT_CHANGE] + + def test_the_song_triggers_its_instruments(self, triggers: List[LoadedRow]) -> None: + assert triggers + + def test_every_trigger_names_an_instrument_the_document_holds( + self, + document: LoadedProject, + triggers: List[LoadedRow], + ) -> None: + numbers = {instrument.number for instrument in document.instruments} + assert {row.instrument for row in triggers} <= numbers + + def test_every_trigger_attaches_a_table_the_document_holds( + self, + document: LoadedProject, + triggers: List[LoadedRow], + ) -> None: + columns = {table.id + TABLE_COLUMN_OFFSET for table in document.tables} + assert {row.table for row in triggers} <= columns + + def test_every_trigger_names_a_pitched_note(self, triggers: List[LoadedRow]) -> None: + assert all(int(NoteName.C) <= row.note.name <= int(NoteName.B) for row in triggers) + + def test_every_note_lands_inside_the_tuning_table(self, triggers: List[LoadedRow]) -> None: + indices = [note_index(row.note) for row in triggers] + assert all(MIN_NOTE_INDEX <= index <= MAX_NOTE_INDEX for index in indices) + + def test_every_volume_column_stays_within_the_channel_range(self, document: LoadedProject) -> None: + assert all(0 <= row.volume <= FULL_VOLUME for row in every_row(document)) + + +class TestTheInstrumentRowsArePlayable: + def test_every_row_holds_a_waveform_the_channel_reads(self, document: LoadedProject) -> None: + rows = [row for instrument in document.instruments for row in instrument.rows] + assert all(MIN_PULSE_WIDTH <= row.pulse_width <= MAX_PULSE_WIDTH for row in rows) + + def test_every_row_holds_a_level_the_channel_reads(self, document: LoadedProject) -> None: + rows = [row for instrument in document.instruments for row in instrument.rows] + assert all(MIN_VOLUME_OR_RATE <= row.volume_or_rate <= MAX_VOLUME_OR_RATE for row in rows) + + def test_every_row_reads_its_level_as_a_literal_volume(self, document: LoadedProject) -> None: + rows = [row for instrument in document.instruments for row in instrument.rows] + assert all(row.envelope is False for row in rows) + + def test_every_row_holds_the_note_for_as_long_as_the_envelope_runs(self, document: LoadedProject) -> None: + rows = [row for instrument in document.instruments for row in instrument.rows] + assert all(row.sound_length == SUSTAINED_SOUND_LENGTH for row in rows) + + def test_every_instrument_loops_on_a_row_it_holds(self, document: LoadedProject) -> None: + """Playback returns to the loop row once it runs off the end, so a loop point + past the last row would leave the instrument nowhere to resume from. + """ + assert all(instrument.loop < len(instrument.rows) for instrument in document.instruments) + + def test_every_table_loops_on_a_row_it_holds(self, document: LoadedProject) -> None: + assert all(table.loop < len(table.rows) for table in document.tables) + + def test_each_instrument_runs_as_long_as_its_table(self, document: LoadedProject) -> None: + """The rows and the table advance on their own per-tick counters, so a length + they share is what keeps the volume envelope aligned with the pitch contour. + """ + lengths = [ + (len(instrument.rows), len(table.rows)) for instrument, table in zip(document.instruments, document.tables) + ] + assert all(rows == table_rows for rows, table_rows in lengths) diff --git a/tests/integration/famitracker/conftest.py b/tests/integration/famitracker/conftest.py index 7089eb12..b10b93fe 100644 --- a/tests/integration/famitracker/conftest.py +++ b/tests/integration/famitracker/conftest.py @@ -1,38 +1,19 @@ -import os -import shutil from pathlib import Path from typing import Optional import pytest -from tests.integration.paths import FTM_OUTPUT_ENV, MODULE_FILENAME, REPO_ROOT +from tests.integration.output import resolve_output_directory, resolve_output_path +from tests.integration.paths import FTM_OUTPUT_ENV, MODULE_FILENAME @pytest.fixture(scope="session") def ftm_output_dir() -> Optional[Path]: - """The persistent output directory, or None when emission is not requested. - - Emission is opt-in via the ``SAMPLETONES_FTM_OUTPUT_DIR`` environment variable so - ordinary (and parallel) runs write only to ``tmp_path``. When set, the directory - is cleaned once per session so each run leaves a fresh set of files. - """ - configured = os.environ.get(FTM_OUTPUT_ENV) - if not configured: - return None - - directory = Path(configured) - if not directory.is_absolute(): - directory = REPO_ROOT / directory - - if directory.exists(): - shutil.rmtree(directory) - - directory.mkdir(parents=True, exist_ok=True) - return directory + """The persistent output directory ``SAMPLETONES_FTM_OUTPUT_DIR`` names.""" + return resolve_output_directory(FTM_OUTPUT_ENV) @pytest.fixture def module_path(ftm_output_dir: Optional[Path], tmp_path: Path) -> Path: - """Where a produced ``.ftm`` is written: the persistent dir if opted in, else tmp.""" - base = ftm_output_dir if ftm_output_dir is not None else tmp_path - return base / MODULE_FILENAME + """Where a produced ``.ftm`` is written.""" + return resolve_output_path(ftm_output_dir, tmp_path, MODULE_FILENAME) diff --git a/tests/integration/famitracker/test_ftm_pipeline.py b/tests/integration/famitracker/test_ftm_pipeline.py index d1e261b4..c9a8e90a 100644 --- a/tests/integration/famitracker/test_ftm_pipeline.py +++ b/tests/integration/famitracker/test_ftm_pipeline.py @@ -4,10 +4,10 @@ from sampletones_application.logic.sequencer.channels import SequencerChannelsLogic from sampletones_core.constants.enums import GeneratorName -from sampletones_core.famitracker.export import write_ftm -from sampletones_core.famitracker.specification.channels import ChannelId -from sampletones_core.famitracker.specification.file import FTM_VERSION -from sampletones_core.famitracker.specification.sequences import SequenceKind +from sampletones_core.formats.famitracker.export import write_ftm +from sampletones_core.formats.famitracker.specification.channels import ChannelId +from sampletones_core.formats.famitracker.specification.file import FTM_VERSION +from sampletones_core.formats.famitracker.specification.sequences import SequenceKind from sampletones_core.project.project import Project from tests.suite.famitracker import ParsedModule, parse_ftm diff --git a/tests/integration/output.py b/tests/integration/output.py new file mode 100644 index 00000000..423b89d3 --- /dev/null +++ b/tests/integration/output.py @@ -0,0 +1,49 @@ +import os +import shutil +from pathlib import Path +from typing import Optional + +from tests.integration.paths import REPO_ROOT + + +def resolve_output_directory(variable: str) -> Optional[Path]: + """Reads the persistent output directory an environment variable names. + + Emission is opt-in so an ordinary (and parallel) run writes only to ``tmp_path``. + A named directory is cleaned once per session, so each run leaves a fresh set of + files there. + + Args: + variable: Environment variable naming the directory. + + Returns: + Optional[Path]: The prepared directory, or ``None`` while emission is unasked for. + """ + configured = os.environ.get(variable) + if not configured: + return None + + directory = Path(configured) + if not directory.is_absolute(): + directory = REPO_ROOT / directory + + if directory.exists(): + shutil.rmtree(directory) + + directory.mkdir(parents=True, exist_ok=True) + return directory + + +def resolve_output_path(output_directory: Optional[Path], tmp_path: Path, filename: str) -> Path: + """Locates a produced file: the persistent directory where one is named, else ``tmp_path``. + + Args: + output_directory: The persistent directory, or ``None`` while emission is unasked for. + tmp_path: The test's own temporary directory. + filename: Name the produced file carries. + + Returns: + Path: Where the file is written. + """ + base = output_directory if output_directory is not None else tmp_path + return base / filename diff --git a/tests/integration/paths.py b/tests/integration/paths.py index 8048706e..2c5d9c57 100644 --- a/tests/integration/paths.py +++ b/tests/integration/paths.py @@ -21,3 +21,6 @@ def _repo_root() -> Path: MODULE_FILENAME: Final[str] = "drums.ftm" FTM_OUTPUT_ENV: Final[str] = "SAMPLETONES_FTM_OUTPUT_DIR" + +DOCUMENT_FILENAME: Final[str] = "drums.btp" +BTP_OUTPUT_ENV: Final[str] = "SAMPLETONES_BTP_OUTPUT_DIR" diff --git a/tests/integration/sampletones_application/services/conftest.py b/tests/integration/sampletones_application/services/conftest.py index decd5bcd..140ca412 100644 --- a/tests/integration/sampletones_application/services/conftest.py +++ b/tests/integration/sampletones_application/services/conftest.py @@ -33,7 +33,10 @@ def pulse_instructions() -> list: @pytest.fixture def pulse_features(pulse_instructions) -> Features: - return PulseExporter().to_features(pulse_instructions) + return PulseExporter().to_features( + pulse_instructions, + PulseExporter.derive_initial_pitch(pulse_instructions), + ) @pytest.fixture diff --git a/tests/integration/sampletones_application/services/test_export.py b/tests/integration/sampletones_application/services/test_export.py index 37f2583b..f0d1939c 100644 --- a/tests/integration/sampletones_application/services/test_export.py +++ b/tests/integration/sampletones_application/services/test_export.py @@ -1,12 +1,38 @@ -from typing import Any, List +from typing import Any, Final, List import numpy as np +import pytest from sampletones_application.services.export.error import ExportError from sampletones_application.services.export.kind import ExportKind from sampletones_application.services.export.service import ExportService from sampletones_application.services.export.success import ExportSuccess from sampletones_core.audio import read_wave +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.exporters import Features +from sampletones_core.trackers.implementation.famitracker import FamiTrackerBackend +from sampletones_core.trackers.request import InstrumentExport, SampleExport + +NES_FREQUENCY: Final[int] = 60 + + +@pytest.fixture(name="backend") +def backend_fixture() -> FamiTrackerBackend: + return FamiTrackerBackend() + + +def instrument_export(name: str, features: Features) -> InstrumentExport: + return InstrumentExport( + name=name, + generator=GeneratorName.PULSE1, + features=features, + loop=False, + nes_frequency=NES_FREQUENCY, + ) + + +def sample_export(name: str, *instruments: InstrumentExport) -> SampleExport: + return SampleExport(name=name, instruments=instruments, nes_frequency=NES_FREQUENCY) class TestExportWavIntegration: @@ -57,85 +83,90 @@ def test_invalid_sample_rate_emits_export_error(self, tmp_path) -> None: class TestExportInstrumentIntegration: - def test_fti_file_is_created_on_disk(self, tmp_path, pulse_features) -> None: + def test_fti_file_is_created_on_disk(self, tmp_path, pulse_features, backend) -> None: export_service = ExportService() results: List[Any] = [] export_service.subscribe(results.append) filepath = tmp_path / "instrument.fti" - export_service.export_instrument(filepath, "test_instrument", pulse_features) + export_service.export_instrument(filepath, backend, instrument_export("test_instrument", pulse_features)) assert filepath.exists() - def test_emits_export_success_with_correct_kind_and_filepath(self, tmp_path, pulse_features) -> None: + def test_emits_export_success_with_correct_kind_and_filepath(self, tmp_path, pulse_features, backend) -> None: export_service = ExportService() results: List[Any] = [] export_service.subscribe(results.append) filepath = tmp_path / "instrument.fti" - export_service.export_instrument(filepath, "test_instrument", pulse_features) + export_service.export_instrument(filepath, backend, instrument_export("test_instrument", pulse_features)) assert len(results) == 1 assert isinstance(results[0], ExportSuccess) assert results[0].kind == ExportKind.INSTRUMENT assert results[0].filepath == filepath - def test_directory_path_emits_export_error(self, tmp_path, pulse_features) -> None: + def test_directory_path_emits_export_error(self, tmp_path, pulse_features, backend) -> None: export_service = ExportService() results: List[Any] = [] export_service.subscribe(results.append) - export_service.export_instrument(tmp_path, "test_instrument", pulse_features) + export_service.export_instrument(tmp_path, backend, instrument_export("test_instrument", pulse_features)) assert len(results) == 1 assert isinstance(results[0], ExportError) assert results[0].kind == ExportKind.INSTRUMENT -class TestExportInstrumentsIntegration: - def test_all_fti_files_are_created_on_disk(self, tmp_path, pulse_features) -> None: +class TestExportSampleIntegration: + def test_all_fti_files_are_created_on_disk(self, tmp_path, pulse_features, backend) -> None: export_service = ExportService() results: List[Any] = [] export_service.subscribe(results.append) - exports = [ - (tmp_path / "inst_0.fti", "inst_0", pulse_features), - (tmp_path / "inst_1.fti", "inst_1", pulse_features), - ] - export_service.export_instruments(tmp_path, exports) + request = sample_export( + "sample", + instrument_export("inst_0", pulse_features), + instrument_export("inst_1", pulse_features), + ) + export_service.export_sample(tmp_path / "sample.fti", backend, request) - for filepath, _, _ in exports: - assert filepath.exists() + assert (tmp_path / "inst_0.fti").exists() + assert (tmp_path / "inst_1.fti").exists() - def test_emits_export_success_with_directory_filepath(self, tmp_path, pulse_features) -> None: + def test_emits_export_success_with_a_path_that_was_written(self, tmp_path, pulse_features, backend) -> None: + """A batch names its slices after the destination, so the result reports one of the + slices it wrote and the dialog announcing it opens a file that is there. + """ export_service = ExportService() results: List[Any] = [] export_service.subscribe(results.append) - exports = [(tmp_path / "inst.fti", "inst", pulse_features)] - export_service.export_instruments(tmp_path, exports) + request = sample_export("sample", instrument_export("inst", pulse_features)) + export_service.export_sample(tmp_path / "sample.fti", backend, request) assert len(results) == 1 assert isinstance(results[0], ExportSuccess) - assert results[0].kind == ExportKind.INSTRUMENTS - assert results[0].filepath == tmp_path + assert results[0].kind == ExportKind.SAMPLE + assert results[0].filepath == tmp_path / "inst.fti" + assert results[0].filepath.exists() - def test_new_directory_is_created(self, tmp_path, pulse_features) -> None: + def test_new_directory_is_created(self, tmp_path, pulse_features, backend) -> None: new_dir = tmp_path / "subdir" export_service = ExportService() export_service.subscribe(lambda _: None) - export_service.export_instruments(new_dir, [(new_dir / "inst.fti", "inst", pulse_features)]) + request = sample_export("sample", instrument_export("inst", pulse_features)) + export_service.export_sample(new_dir / "sample.fti", backend, request) assert new_dir.exists() - def test_empty_exports_list_creates_no_files(self, tmp_path) -> None: + def test_a_sample_with_no_slices_creates_no_files(self, tmp_path, backend) -> None: export_service = ExportService() results: List[Any] = [] export_service.subscribe(results.append) - export_service.export_instruments(tmp_path, []) + export_service.export_sample(tmp_path / "sample.fti", backend, sample_export("sample")) - fti_files = list(tmp_path.glob("*.fti")) - assert fti_files == [] + assert list(tmp_path.glob("*.fti")) == [] assert isinstance(results[0], ExportSuccess) diff --git a/tests/integration/sampletones_application/services/test_regeneration.py b/tests/integration/sampletones_application/services/test_regeneration.py index 786f35a8..acb2d48a 100644 --- a/tests/integration/sampletones_application/services/test_regeneration.py +++ b/tests/integration/sampletones_application/services/test_regeneration.py @@ -1,13 +1,18 @@ +from dataclasses import dataclass, field from typing import Any, List from unittest.mock import patch import numpy as np import pytest +from sampletones_application.logic.reconstruction.feature import FeatureData from sampletones_application.services.regeneration import RegenerationService from sampletones_application.services.result import ServiceError, ServiceSuccess from sampletones_application.utils.callbacks.queue import CallbackQueue from sampletones_core.constants.enums import FeatureKey, GeneratorName +from sampletones_core.exporters import Features +from sampletones_core.reconstructions import Reconstruction +from tests.suite.scenario import BaseTestScenario, ScenarioStep _real_queue_add = CallbackQueue.add @@ -18,6 +23,9 @@ DELIVERY_BUDGET_FRAMES = 10 SETTLE_DELAY_FRAMES = 500 +BASE_PITCH = 60 +OCTAVE = 12 + class TestRegenerationServicePipeline: """Full synthesis pipeline: real Config, Features (via PulseExporter), real PulseGenerator, @@ -139,6 +147,97 @@ def test_start_completes_through_full_pipeline(self, reconstruction_data, pulse_ assert isinstance(results[0], ServiceSuccess) +@dataclass +class ArpeggioEditContext: + reconstruction: Reconstruction + features: Features + history: List[List[int]] = field(default_factory=list) + + +def _edit_arpeggio(context: ArpeggioEditContext, arpeggio: np.ndarray) -> None: + """Applies an arpeggio envelope through the real regeneration pipeline. + + Each edit runs on its own service, exactly as the instruments panel drives one, and the + regenerated reconstruction replaces the context's own so the next edit continues from it. + """ + service = RegenerationService() + results: List[Any] = [] + service.subscribe(results.append) + + service._run( + context.reconstruction, + GeneratorName.PULSE1, + context.features, + FeatureKey.ARPEGGIO, + arpeggio, + ) + + assert len(results) == 1 + assert isinstance(results[0], ServiceSuccess) + context.reconstruction = results[0].value.reconstruction + context.history.append(_pitches(context)) + + +def _pitches(context: ArpeggioEditContext) -> List[int]: + instructions = context.reconstruction.get_generator_instructions(GeneratorName.PULSE1) + return [instruction.pitch for instruction in instructions] + + +class TestArpeggioEditKeepsTheSamplePitch: + """The reported bug, end to end on the real pipeline. + + Typing an arpeggio envelope into a channel and clearing it again sounds the sample at the + note it was reconstructed at. The reference pitch travels with the instructions each edit + produces, so the second edit measures its offsets from the base the first one started at. + """ + + def test_clearing_an_arpeggio_returns_the_sample_to_its_pitch(self, reconstruction_data) -> None: + def build() -> ArpeggioEditContext: + reconstruction = reconstruction_data.reconstruction + return ArpeggioEditContext( + reconstruction=reconstruction, + features=FeatureData.load(reconstruction)[GeneratorName.PULSE1], + ) + + def check_the_starting_reference(context: ArpeggioEditContext) -> None: + assert context.features.initial_pitch == BASE_PITCH + assert context.features.arpeggio.tolist() == [0] + + def raise_the_first_frame_an_octave(context: ArpeggioEditContext) -> None: + _edit_arpeggio(context, np.array([OCTAVE, 0, 0, 0], dtype=np.int8)) + assert _pitches(context) == [BASE_PITCH + OCTAVE] + [BASE_PITCH] * 3 + + def reload_the_edited_features(context: ArpeggioEditContext) -> None: + context.features = FeatureData.load(context.reconstruction)[GeneratorName.PULSE1] + assert context.features.initial_pitch == BASE_PITCH + assert context.features.arpeggio.tolist() == [OCTAVE, 0] + + def clear_the_envelope(context: ArpeggioEditContext) -> None: + _edit_arpeggio(context, np.zeros(len(context.features.arpeggio), dtype=np.int8)) + assert _pitches(context) == [BASE_PITCH] * 4 + + def check_the_reference_held(context: ArpeggioEditContext) -> None: + reloaded = FeatureData.load(context.reconstruction)[GeneratorName.PULSE1] + assert reloaded.initial_pitch == BASE_PITCH + assert reloaded.arpeggio.tolist() == [0] + + scenario = BaseTestScenario( + label="arpeggio_edit_keeps_the_sample_pitch", + build=build, + steps=[ + ScenarioStep(label="check_the_starting_reference", action=check_the_starting_reference), + ScenarioStep(label="raise_the_first_frame_an_octave", action=raise_the_first_frame_an_octave), + ScenarioStep(label="reload_the_edited_features", action=reload_the_edited_features), + ScenarioStep(label="clear_the_envelope", action=clear_the_envelope), + ScenarioStep(label="check_the_reference_held", action=check_the_reference_held), + ], + ) + + context = scenario.run() + + assert context.history[-1] == [BASE_PITCH] * 4 + + class TestRegenerationDeliveryThroughRealQueue: """Regression guard for the frame-gate starvation that froze reconstruction regeneration. diff --git a/tests/suite/bitphase.py b/tests/suite/bitphase.py new file mode 100644 index 00000000..584d468d --- /dev/null +++ b/tests/suite/bitphase.py @@ -0,0 +1,233 @@ +import gzip +import json +from dataclasses import dataclass +from typing import Any, Dict, Final, List, Optional, Tuple + +BITPHASE_DEFAULT_NAME: Final[str] = "" +BITPHASE_DEFAULT_AUTHOR: Final[str] = "" +BITPHASE_DEFAULT_LOOP_POINT: Final[int] = 0 +BITPHASE_DEFAULT_PATTERN_ORDER: Final[Tuple[int, ...]] = (0,) +BITPHASE_DEFAULT_PATTERN_LENGTH: Final[int] = 64 +BITPHASE_DEFAULT_ROW_COUNT: Final[int] = 64 +BITPHASE_DEFAULT_INTERRUPT_FREQUENCY: Final[int] = 50 +BITPHASE_DEFAULT_INITIAL_SPEED: Final[int] = 3 +BITPHASE_DEFAULT_CHIP_VARIANT: Final[str] = "NTSC" +BITPHASE_DEFAULT_A4_TUNING: Final[float] = 440.0 +BITPHASE_DEFAULT_CHIP_TYPE: Final[str] = "ay" +BITPHASE_DEFAULT_NOTE_NAME: Final[int] = 0 +BITPHASE_DEFAULT_OCTAVE: Final[int] = 0 +BITPHASE_DEFAULT_INSTRUMENT_ID: Final[str] = "01" +BITPHASE_DEFAULT_LOOP: Final[int] = 0 +BITPHASE_DEFAULT_TABLE_ID: Final[int] = 0 +BITPHASE_DEFAULT_PULSE_WIDTH: Final[int] = 2 +BITPHASE_DEFAULT_VOLUME_OR_RATE: Final[int] = 15 + +MIN_INITIAL_SPEED: Final[int] = 1 +MAX_INITIAL_SPEED: Final[int] = 255 + + +@dataclass(frozen=True) +class LoadedNote: + name: int + octave: int + + +@dataclass(frozen=True) +class LoadedRow: + note: LoadedNote + instrument: int + table: int + volume: int + + +@dataclass(frozen=True) +class LoadedChannel: + label: str + rows: List[LoadedRow] + + +@dataclass(frozen=True) +class LoadedPattern: + id: int + length: int + channels: List[LoadedChannel] + + +@dataclass(frozen=True) +class LoadedInstrumentRow: + pulse_width: int + volume_or_rate: int + envelope: bool + sound_length: int + tone_add: int + tone_accumulation: bool + retrigger: bool + sweep: bool + sweep_rate: int + sweep_shift: int + + +@dataclass(frozen=True) +class LoadedInstrument: + id: str + chip_type: str + loop: int + name: str + rows: List[LoadedInstrumentRow] + + @property + def number(self) -> int: + """The value a pattern's instrument column carries to play this instrument.""" + return int(self.id, 36) + + +@dataclass(frozen=True) +class LoadedTable: + id: int + loop: int + name: str + rows: List[int] + + +@dataclass(frozen=True) +class LoadedSong: + chip_type: Optional[str] + chip_variant: str + chip_frequency: Optional[int] + interrupt_frequency: int + a4_tuning_hz: float + initial_speed: int + tuning_table: List[int] + patterns: List[LoadedPattern] + + +@dataclass(frozen=True) +class LoadedProject: + name: str + author: str + loop_point_id: int + pattern_order: List[int] + songs: List[LoadedSong] + tables: List[LoadedTable] + instruments: List[LoadedInstrument] + + +def _note(data: Optional[Dict[str, Any]]) -> LoadedNote: + source = data or {} + return LoadedNote( + name=source.get("name", BITPHASE_DEFAULT_NOTE_NAME), + octave=source.get("octave", BITPHASE_DEFAULT_OCTAVE), + ) + + +def _row(data: Dict[str, Any]) -> LoadedRow: + return LoadedRow( + note=_note(data.get("note")), + instrument=data.get("instrument", 0), + table=data.get("table", 0), + volume=data.get("volume", 0), + ) + + +def _channel(data: Dict[str, Any], label: str) -> LoadedChannel: + rows = data.get("rows") + if rows is None: + return LoadedChannel(label=label, rows=[]) + + return LoadedChannel(label=label, rows=[_row(row) for row in rows]) + + +def _pattern(data: Dict[str, Any], labels: List[str]) -> LoadedPattern: + channels = data.get("channels") or [] + return LoadedPattern( + id=data.get("id", 0), + length=data.get("length", BITPHASE_DEFAULT_PATTERN_LENGTH), + channels=[ + _channel(channel, labels[index] if index < len(labels) else chr(ord("A") + index)) + for index, channel in enumerate(channels) + ], + ) + + +def _instrument_row(data: Dict[str, Any]) -> LoadedInstrumentRow: + return LoadedInstrumentRow( + pulse_width=data.get("pulseWidth", BITPHASE_DEFAULT_PULSE_WIDTH), + volume_or_rate=data.get("volumeOrRate", BITPHASE_DEFAULT_VOLUME_OR_RATE), + envelope=bool(data.get("envelope", False)), + sound_length=data.get("soundLength", 0), + tone_add=data.get("toneAdd", 0), + tone_accumulation=bool(data.get("toneAccumulation", False)), + retrigger=bool(data.get("retrigger", False)), + sweep=bool(data.get("sweep", False)), + sweep_rate=data.get("sweepRate", 0), + sweep_shift=data.get("sweepShift", 0), + ) + + +def _instrument(data: Dict[str, Any]) -> LoadedInstrument: + identifier = data.get("id") + chip_type = data.get("chipType") + return LoadedInstrument( + id=identifier if isinstance(identifier, str) else BITPHASE_DEFAULT_INSTRUMENT_ID, + chip_type=chip_type if isinstance(chip_type, str) else BITPHASE_DEFAULT_CHIP_TYPE, + loop=data.get("loop", BITPHASE_DEFAULT_LOOP), + name=data.get("name", BITPHASE_DEFAULT_NAME), + rows=[_instrument_row(row) for row in data.get("rows") or []], + ) + + +def _table(data: Dict[str, Any]) -> LoadedTable: + return LoadedTable( + id=data.get("id", BITPHASE_DEFAULT_TABLE_ID), + loop=data.get("loop", BITPHASE_DEFAULT_LOOP), + name=data.get("name", BITPHASE_DEFAULT_NAME), + rows=list(data.get("rows") or []), + ) + + +def _initial_speed(data: Dict[str, Any]) -> int: + speed = data.get("initialSpeed") + if isinstance(speed, int) and MIN_INITIAL_SPEED <= speed <= MAX_INITIAL_SPEED: + return speed + + return BITPHASE_DEFAULT_INITIAL_SPEED + + +def _song(data: Dict[str, Any], labels: List[str]) -> LoadedSong: + return LoadedSong( + chip_type=data.get("chipType"), + chip_variant=data.get("chipVariant", BITPHASE_DEFAULT_CHIP_VARIANT), + chip_frequency=data.get("chipFrequency"), + interrupt_frequency=data.get("interruptFrequency", BITPHASE_DEFAULT_INTERRUPT_FREQUENCY), + a4_tuning_hz=data.get("a4TuningHz", BITPHASE_DEFAULT_A4_TUNING), + initial_speed=_initial_speed(data), + tuning_table=list(data.get("tuningTable") or []), + patterns=[_pattern(pattern, labels) for pattern in data.get("patterns") or []], + ) + + +def parse_btp(data: bytes, channel_labels: List[str]) -> LoadedProject: + """Reads a ``.btp`` the way Bitphase's project loader does. + + The loader takes each field on its own and falls back to a default for any it + misses, so reading a document through the same fallbacks turns a field left out + into the default value the assertion catches. + + Args: + data: The file's contents. + channel_labels: Channel names the chip schema supplies, which the loader + assigns to a pattern's channels by position. + + Returns: + LoadedProject: The document as Bitphase reconstructs it. + """ + document: Dict[str, Any] = json.loads(gzip.decompress(data)) + return LoadedProject( + name=document.get("name", BITPHASE_DEFAULT_NAME), + author=document.get("author", BITPHASE_DEFAULT_AUTHOR), + loop_point_id=document.get("loopPointId", BITPHASE_DEFAULT_LOOP_POINT), + pattern_order=list(document.get("patternOrder") or BITPHASE_DEFAULT_PATTERN_ORDER), + songs=[_song(song, channel_labels) for song in document.get("songs") or []], + tables=[_table(table) for table in document.get("tables") or []], + instruments=[_instrument(instrument) for instrument in document.get("instruments") or []], + ) diff --git a/tests/suite/famitracker.py b/tests/suite/famitracker.py index 9f19b3b0..66de2bc7 100644 --- a/tests/suite/famitracker.py +++ b/tests/suite/famitracker.py @@ -2,13 +2,13 @@ from dataclasses import dataclass from typing import Dict, List, Tuple -from sampletones_core.famitracker.specification.blocks import BLOCK_NAME_LENGTH -from sampletones_core.famitracker.specification.file import FTM_END_MARKER, FTM_MAGIC -from sampletones_core.famitracker.specification.instruments import ( +from sampletones_core.formats.famitracker.specification.blocks import BLOCK_NAME_LENGTH +from sampletones_core.formats.famitracker.specification.file import FTM_END_MARKER, FTM_MAGIC +from sampletones_core.formats.famitracker.specification.instruments import ( DPCM_KEY_ASSIGNMENTS, DPCM_KEY_BYTES, ) -from sampletones_core.famitracker.specification.sequences import SEQUENCE_COUNT_2A03 +from sampletones_core.formats.famitracker.specification.sequences import SEQUENCE_COUNT_2A03 class _Cursor: @@ -156,6 +156,7 @@ def _read_blocks(cursor: _Cursor) -> Tuple[Dict[str, bytes], Dict[str, int]]: size = cursor.read_int32() payloads[name] = cursor.read(size) versions[name] = version + return payloads, versions @@ -191,6 +192,7 @@ def _parse_header(payload: bytes, channel_count: int) -> ParsedHeader: channel_id = cursor.read_uint8() effect_columns = cursor.read_uint8() + 1 channels.append(ParsedChannelHeader(channel_id=channel_id, effect_columns=effect_columns)) + return ParsedHeader(track_count=track_count, track_titles=track_titles, channels=channels) @@ -212,6 +214,7 @@ def _parse_instruments(payload: bytes) -> List[ParsedInstrument]: instruments.append( ParsedInstrument(index=index, instrument_type=instrument_type, sequence_refs=refs, name=name) ) + return instruments @@ -238,6 +241,7 @@ def _parse_sequences(payload: bytes) -> List[ParsedSequence]: for sequence in sequences: sequence.release_point = cursor.read_int32() sequence.setting = cursor.read_int32() + return sequences diff --git a/tests/unit/sampletones_application/categories/test_trackers.py b/tests/unit/sampletones_application/categories/test_trackers.py new file mode 100644 index 00000000..7f42d3e7 --- /dev/null +++ b/tests/unit/sampletones_application/categories/test_trackers.py @@ -0,0 +1,109 @@ +from typing import Dict, FrozenSet, Set + +import pytest + +from sampletones_application.categories.trackers import ( + INSTRUMENT_EXPORT_FORMATS, + TRACKER_INSTRUMENT_FILTERS, + TRACKER_PROJECT_ELEMENTS, + TRACKER_PROJECT_MENU_LABELS, + TRACKER_SAMPLE_MENU_LABELS, +) +from sampletones_application.utils.gui.shortcuts.ids import ( + PROJECT_EXPORT_SHORTCUT_IDS, + SAMPLE_EXPORT_SHORTCUT_IDS, +) +from sampletones_core.trackers.backend import TrackerBackend +from sampletones_core.trackers.format import TrackerFormat +from sampletones_core.trackers.registry import build_tracker_backends +from sampletones_core.trackers.scope import ExportScope + + +@pytest.fixture(name="backends") +def backends_fixture() -> Dict[TrackerFormat, TrackerBackend]: + return build_tracker_backends() + + +def formats_supporting( + backends: Dict[TrackerFormat, TrackerBackend], + scope: ExportScope, +) -> Set[TrackerFormat]: + return {tracker_format for tracker_format, backend in backends.items() if scope in backend.supported_scopes} + + +class TestEveryOfferedFormatHasABackend: + """A menu entry reaches a backend through the registry, so an entry the registry has no + backend for would raise a ``KeyError`` the moment it is chosen.""" + + @pytest.mark.parametrize( + "offered", + [ + frozenset(PROJECT_EXPORT_SHORTCUT_IDS), + frozenset(TRACKER_PROJECT_MENU_LABELS), + frozenset(TRACKER_PROJECT_ELEMENTS), + frozenset(SAMPLE_EXPORT_SHORTCUT_IDS), + frozenset(TRACKER_SAMPLE_MENU_LABELS), + frozenset(INSTRUMENT_EXPORT_FORMATS), + ], + ids=[ + "project_shortcuts", + "project_menu", + "project_elements", + "sample_shortcuts", + "sample_menu", + "instrument_button", + ], + ) + def test_the_registry_builds_every_offered_format( + self, + backends: Dict[TrackerFormat, TrackerBackend], + offered: FrozenSet[TrackerFormat], + ) -> None: + assert offered <= frozenset(backends) + + +class TestTheMenusMatchTheSupportedScopes: + """The project submenu lists exactly the formats whose backend writes a project, so a + format gains its entry by declaring the scope rather than by a second edit in the UI. + + An instrument export offers a chosen few of the formats able to write its scope, so each + offered format is required to write that scope while the reverse stays a curated choice. + """ + + def test_the_project_export_menu_lists_the_formats_that_write_a_project( + self, + backends: Dict[TrackerFormat, TrackerBackend], + ) -> None: + assert set(TRACKER_PROJECT_MENU_LABELS) == formats_supporting(backends, ExportScope.PROJECT) + + def test_the_instruments_menu_offers_formats_that_write_a_whole_sample( + self, + backends: Dict[TrackerFormat, TrackerBackend], + ) -> None: + assert set(TRACKER_SAMPLE_MENU_LABELS) <= formats_supporting(backends, ExportScope.SAMPLE) + + def test_the_instrument_button_offers_formats_that_write_one_slice( + self, + backends: Dict[TrackerFormat, TrackerBackend], + ) -> None: + assert set(INSTRUMENT_EXPORT_FORMATS) <= formats_supporting(backends, ExportScope.INSTRUMENT) + + +class TestEveryMenuEntryCarriesAnAction: + """A submenu builds its entries by pairing a shortcut id with a label, so the two maps + cover the same formats.""" + + def test_the_project_menu_pairs_every_label_with_a_shortcut(self) -> None: + assert set(TRACKER_PROJECT_MENU_LABELS) == set(PROJECT_EXPORT_SHORTCUT_IDS) + + def test_the_instruments_menu_pairs_every_label_with_a_shortcut(self) -> None: + assert set(TRACKER_SAMPLE_MENU_LABELS) == set(SAMPLE_EXPORT_SHORTCUT_IDS) + + +class TestEveryOfferedFormatIsNamedInItsDialog: + """A save dialog offers each format under its own file type, so a format an export offers + without a type name would reach the dialog unnamed.""" + + def test_every_instrument_export_format_carries_a_file_type(self) -> None: + offered = set(INSTRUMENT_EXPORT_FORMATS) | set(TRACKER_SAMPLE_MENU_LABELS) + assert offered <= set(TRACKER_INSTRUMENT_FILTERS) diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_reconstruction.py b/tests/unit/sampletones_application/coordinators/tabs/test_reconstruction.py index 70ca3118..e8a5f3d5 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_reconstruction.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_reconstruction.py @@ -1,4 +1,4 @@ -from pathlib import Path +from pathlib import Path from unittest.mock import MagicMock import pytest @@ -11,7 +11,8 @@ from sampletones_application.paths import LANG_EN from sampletones_application.services.export.kind import ExportKind from sampletones_application.services.export.success import ExportSuccess -from sampletones_application.services.export.truncation import ExportTruncation +from sampletones_core.exporters.truncation import EnvelopeTruncation +from sampletones_core.trackers.format import TrackerFormat from sampletones_shared.exceptions import ( DeserializationError, IncompatibleReconstructionVersionError, @@ -247,7 +248,12 @@ def test_a_complete_instrument_export_shows_the_success_message( export_coordinator: ReconstructionTabCoordinator, ) -> None: export_coordinator._on_export_result( - ExportSuccess(kind=ExportKind.INSTRUMENT, filepath=Path("lead.fti"), truncation=None) + ExportSuccess( + kind=ExportKind.INSTRUMENT, + filepath=Path("lead.fti"), + tracker_format=TrackerFormat.FAMITRACKER, + truncation=None, + ) ) assert _shown_message(export_coordinator) == export_coordinator._export_messages.instrument_success @@ -260,7 +266,8 @@ def test_a_shortened_instrument_export_names_both_frame_counts( ExportSuccess( kind=ExportKind.INSTRUMENT, filepath=Path("lead.fti"), - truncation=ExportTruncation(frames=252, source_frames=300, instruments=1), + tracker_format=TrackerFormat.FAMITRACKER, + truncation=EnvelopeTruncation(frames=252, source_frames=300, instruments=1), ) ) @@ -275,9 +282,10 @@ def test_a_shortened_reconstruction_export_counts_the_instruments( ) -> None: export_coordinator._on_export_result( ExportSuccess( - kind=ExportKind.INSTRUMENTS, + kind=ExportKind.SAMPLE, filepath=Path("instruments"), - truncation=ExportTruncation(frames=252, source_frames=410, instruments=3), + tracker_format=TrackerFormat.FAMITRACKER, + truncation=EnvelopeTruncation(frames=252, source_frames=410, instruments=3), ) ) @@ -290,7 +298,7 @@ def test_a_wav_export_shows_its_own_message( export_coordinator: ReconstructionTabCoordinator, ) -> None: export_coordinator._on_export_result( - ExportSuccess(kind=ExportKind.WAV, filepath=Path("track.wav"), truncation=None) + ExportSuccess(kind=ExportKind.WAV, filepath=Path("track.wav"), tracker_format=None, truncation=None) ) assert _shown_message(export_coordinator) == export_coordinator._export_messages.wav_success diff --git a/tests/unit/sampletones_application/coordinators/test_project.py b/tests/unit/sampletones_application/coordinators/test_project.py index 1b84c113..c821a1f5 100644 --- a/tests/unit/sampletones_application/coordinators/test_project.py +++ b/tests/unit/sampletones_application/coordinators/test_project.py @@ -23,6 +23,8 @@ def project_coordinator() -> ProjectCoordinator: MagicMock(), MagicMock(), MagicMock(), + MagicMock(), + tracker_backends={}, dialogs=MagicMock(), language_manager=MagicMock(), on_tab_switch=MagicMock(), diff --git a/tests/unit/sampletones_application/logic/project/test_controller.py b/tests/unit/sampletones_application/logic/project/test_controller.py index dfc75109..ed2e55dd 100644 --- a/tests/unit/sampletones_application/logic/project/test_controller.py +++ b/tests/unit/sampletones_application/logic/project/test_controller.py @@ -508,6 +508,7 @@ def test_in_place_reconstruction_edit_is_visible_through_project( GeneratorName.PULSE1, new_instructions, np.zeros(64, dtype=np.float32), + 72, ) stored = controller.project.sample(sample.id).reconstruction diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py index 6bf15c98..242e85f6 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py @@ -1,8 +1,8 @@ -from __future__ import annotations +from __future__ import annotations from dataclasses import dataclass from pathlib import Path -from typing import Callable, List +from typing import Callable, Dict, Final, List from unittest.mock import MagicMock import numpy as np @@ -18,7 +18,32 @@ from sampletones_core.audio import write_wave from sampletones_core.configs import Config from sampletones_core.constants.enums import AudioSourceType, GeneratorName +from sampletones_core.paths import ( + EXT_FILE_BITPHASE, + EXT_FILE_INSTRUMENT, + EXT_FILE_JSON, + EXT_FILE_MODULE, +) from sampletones_core.reconstructions import Reconstruction +from sampletones_core.trackers.format import TrackerFormat +from sampletones_core.trackers.registry import build_tracker_backends + +NO_EXTENSION: Final[str] = "" + + +@dataclass(frozen=True) +class FormatCase: + extension: str + tracker_format: TrackerFormat + + +INSTRUMENT_FORMAT_CASES: Final[List[FormatCase]] = [ + FormatCase(extension=EXT_FILE_INSTRUMENT, tracker_format=TrackerFormat.FAMITRACKER), + FormatCase(extension=EXT_FILE_BITPHASE, tracker_format=TrackerFormat.BITPHASE), + FormatCase(extension=EXT_FILE_JSON, tracker_format=TrackerFormat.BITPHASE_PRESET), +] + +UNSUPPORTED_EXTENSIONS: Final[List[str]] = [".xm", EXT_FILE_MODULE, NO_EXTENSION] @pytest.fixture @@ -47,8 +72,31 @@ def panel_logic( session_manager: MagicMock, mock_reconstruction_manager: MagicMock, mock_export_service: MagicMock, + mock_tracker_backends: Dict[TrackerFormat, MagicMock], ) -> ReconstructionPanelLogic: - return ReconstructionPanelLogic(session_manager, mock_reconstruction_manager, mock_export_service) + return ReconstructionPanelLogic( + session_manager, + mock_reconstruction_manager, + mock_export_service, + mock_tracker_backends, + ) + + +@pytest.fixture +def mock_tracker_backends() -> Dict[TrackerFormat, MagicMock]: + """Stands in for the real backends while declaring the scopes and extensions they do. + + The logic reads the destination's extension to pick a backend, so each stub mirrors what + the registry's backend declares and leaves only the writing to the mock. + """ + backends: Dict[TrackerFormat, MagicMock] = {} + for tracker_format, backend in build_tracker_backends().items(): + stub = MagicMock() + stub.supported_scopes = backend.supported_scopes + stub.extension.side_effect = backend.extension + backends[tracker_format] = stub + + return backends @pytest.fixture @@ -375,6 +423,21 @@ def test_request_export_instrument_dialog_fires_dialog_callback( panel_logic.request_export_instrument_dialog(GeneratorName.PULSE1) callback.assert_called_once() + def test_request_export_instrument_dialog_suggests_the_slice_name( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + loaded_data: ReconstructionData, + ) -> None: + """The suggestion is the slice name alone, leaving the tracker to the dialog's own + file-type selector. + """ + mock_reconstruction_manager.current_reconstruction = loaded_data + callback = MagicMock() + panel_logic.on_open_export_instrument_dialog = callback + panel_logic.request_export_instrument_dialog(GeneratorName.PULSE1) + assert callback.call_args.args[0] == "Sample (pulse1)" + def test_request_export_instrument_dialog_for_unknown_generator_is_no_op( self, panel_logic: ReconstructionPanelLogic, @@ -387,16 +450,26 @@ def test_request_export_instrument_dialog_for_unknown_generator_is_no_op( panel_logic.request_export_instrument_dialog(GeneratorName.TRIANGLE) callback.assert_not_called() - def test_handle_export_instrument_confirmed_with_no_pending_does_not_export( + def test_request_export_instrument_dialog_sends_the_generator_to_the_dialog( self, panel_logic: ReconstructionPanelLogic, mock_reconstruction_manager: MagicMock, loaded_data: ReconstructionData, + ) -> None: + """The generator travels with the request, so the confirmation names it back.""" + mock_reconstruction_manager.current_reconstruction = loaded_data + callback = MagicMock() + panel_logic.on_open_export_instrument_dialog = callback + panel_logic.request_export_instrument_dialog(GeneratorName.PULSE1) + assert callback.call_args.args[2] == GeneratorName.PULSE1 + + def test_handle_export_instrument_confirmed_with_no_data_does_not_export( + self, + panel_logic: ReconstructionPanelLogic, mock_export_service: MagicMock, tmp_path: Path, ) -> None: - mock_reconstruction_manager.current_reconstruction = loaded_data - panel_logic.handle_export_instrument_confirmed(tmp_path / "instrument.fti") + panel_logic.handle_export_instrument_confirmed(tmp_path / "instrument.fti", GeneratorName.PULSE1) mock_export_service.export_instrument.assert_not_called() def test_handle_export_instrument_confirmed_calls_export_service( @@ -408,11 +481,60 @@ def test_handle_export_instrument_confirmed_calls_export_service( tmp_path: Path, ) -> None: mock_reconstruction_manager.current_reconstruction = loaded_data - panel_logic.on_open_export_instrument_dialog = MagicMock() - panel_logic.request_export_instrument_dialog(GeneratorName.PULSE1) - panel_logic.handle_export_instrument_confirmed(tmp_path / "instrument.fti") + panel_logic.handle_export_instrument_confirmed(tmp_path / "instrument.fti", GeneratorName.PULSE1) mock_export_service.export_instrument.assert_called_once() + def test_handle_export_instrument_confirmed_names_the_instrument_after_the_destination( + 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_instrument_confirmed(tmp_path / "Clap (pulse1).fti", GeneratorName.PULSE1) + request = mock_export_service.export_instrument.call_args.args[2] + assert request.name == "Clap (pulse1)" + + @pytest.mark.parametrize("case", INSTRUMENT_FORMAT_CASES, ids=lambda case: case.extension) + def test_handle_export_instrument_confirmed_selects_the_backend_the_extension_names( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + loaded_data: ReconstructionData, + mock_export_service: MagicMock, + mock_tracker_backends: Dict[TrackerFormat, MagicMock], + tmp_path: Path, + case: FormatCase, + ) -> None: + mock_reconstruction_manager.current_reconstruction = loaded_data + panel_logic.handle_export_instrument_confirmed( + tmp_path / f"instrument{case.extension}", + GeneratorName.PULSE1, + ) + backend = mock_export_service.export_instrument.call_args.args[1] + assert backend is mock_tracker_backends[case.tracker_format] + + @pytest.mark.parametrize("extension", UNSUPPORTED_EXTENSIONS) + def test_handle_export_instrument_confirmed_refuses_an_extension_no_format_writes( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + loaded_data: ReconstructionData, + tmp_path: Path, + extension: str, + ) -> None: + """The dialog answers with one of the types it offered, so an extension naming no + format is a broken invariant rather than a choice to report. + """ + mock_reconstruction_manager.current_reconstruction = loaded_data + with pytest.raises(ValueError): + panel_logic.handle_export_instrument_confirmed( + tmp_path / f"instrument{extension}", + GeneratorName.PULSE1, + ) + class TestReconstructionPanelLogicExportInstruments: def test_request_export_instruments_dialog_with_no_data_raises_assertion_error( @@ -420,7 +542,7 @@ def test_request_export_instruments_dialog_with_no_data_raises_assertion_error( panel_logic: ReconstructionPanelLogic, ) -> None: with pytest.raises(AssertionError): - panel_logic.request_export_instruments_dialog() + panel_logic.request_export_instruments_dialog(TrackerFormat.FAMITRACKER) def test_request_export_instruments_dialog_fires_dialog_callback( self, @@ -431,19 +553,50 @@ def test_request_export_instruments_dialog_fires_dialog_callback( mock_reconstruction_manager.current_reconstruction = loaded_data callback = MagicMock() panel_logic.on_open_export_instruments_dialog = callback - panel_logic.request_export_instruments_dialog() + panel_logic.request_export_instruments_dialog(TrackerFormat.FAMITRACKER) callback.assert_called_once() + def test_request_export_instruments_dialog_suggests_the_reconstruction_name( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + loaded_data: ReconstructionData, + ) -> None: + """The tracker is settled before the dialog opens, so the suggestion ends in the + extension that tracker writes. + """ + mock_reconstruction_manager.current_reconstruction = loaded_data + callback = MagicMock() + panel_logic.on_open_export_instruments_dialog = callback + panel_logic.request_export_instruments_dialog(TrackerFormat.FAMITRACKER) + assert callback.call_args.args[0] == f"{loaded_data.name}{EXT_FILE_INSTRUMENT}" + + def test_request_export_instruments_dialog_carries_the_chosen_tracker( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + loaded_data: ReconstructionData, + ) -> None: + """The dialog offers one type, so the tracker travels with the request.""" + mock_reconstruction_manager.current_reconstruction = loaded_data + callback = MagicMock() + panel_logic.on_open_export_instruments_dialog = callback + panel_logic.request_export_instruments_dialog(TrackerFormat.BITPHASE_PRESET) + assert callback.call_args.args[2] == TrackerFormat.BITPHASE_PRESET + def test_handle_export_instruments_confirmed_with_no_data_is_no_op( self, panel_logic: ReconstructionPanelLogic, mock_export_service: MagicMock, tmp_path: Path, ) -> None: - panel_logic.handle_export_instruments_confirmed(tmp_path) - mock_export_service.export_instruments.assert_not_called() + panel_logic.handle_export_instruments_confirmed( + tmp_path / "sample.fti", + TrackerFormat.FAMITRACKER, + ) + mock_export_service.export_sample.assert_not_called() - def test_handle_export_instruments_confirmed_calls_export_instruments( + def test_handle_export_instruments_confirmed_calls_export_sample( self, panel_logic: ReconstructionPanelLogic, mock_reconstruction_manager: MagicMock, @@ -452,8 +605,50 @@ def test_handle_export_instruments_confirmed_calls_export_instruments( tmp_path: Path, ) -> None: mock_reconstruction_manager.current_reconstruction = loaded_data - panel_logic.handle_export_instruments_confirmed(tmp_path) - mock_export_service.export_instruments.assert_called_once() + panel_logic.handle_export_instruments_confirmed( + tmp_path / "sample.fti", + TrackerFormat.FAMITRACKER, + ) + mock_export_service.export_sample.assert_called_once() + + def test_handle_export_instruments_confirmed_names_the_batch_after_the_destination( + 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 / "Clap.fti", + TrackerFormat.FAMITRACKER, + ) + request = mock_export_service.export_sample.call_args.args[2] + assert request.name == "Clap" + assert [instrument.name for instrument in request.instruments] == ["Clap (pulse1)"] + + @pytest.mark.parametrize("case", INSTRUMENT_FORMAT_CASES, ids=lambda case: case.extension) + def test_handle_export_instruments_confirmed_writes_through_the_chosen_tracker( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + loaded_data: ReconstructionData, + mock_export_service: MagicMock, + mock_tracker_backends: Dict[TrackerFormat, MagicMock], + tmp_path: Path, + case: FormatCase, + ) -> None: + """The action names the tracker, so the destination's own extension leaves the + backend it is written through untouched. + """ + mock_reconstruction_manager.current_reconstruction = loaded_data + panel_logic.handle_export_instruments_confirmed( + tmp_path / f"sample{case.extension}", + case.tracker_format, + ) + backend = mock_export_service.export_sample.call_args.args[1] + assert backend is mock_tracker_backends[case.tracker_format] class TestReconstructionPanelLogicExportWav: diff --git a/tests/unit/sampletones_application/services/export/test_result.py b/tests/unit/sampletones_application/services/export/test_result.py index bce41c0f..a1743601 100644 --- a/tests/unit/sampletones_application/services/export/test_result.py +++ b/tests/unit/sampletones_application/services/export/test_result.py @@ -6,55 +6,80 @@ from sampletones_application.services.export.error import ExportError from sampletones_application.services.export.kind import ExportKind from sampletones_application.services.export.success import ExportSuccess -from sampletones_application.services.export.truncation import ExportTruncation +from sampletones_core.exporters.truncation import EnvelopeTruncation +from sampletones_core.trackers.format import TrackerFormat class TestExportSuccess: def test_stores_kind_and_filepath(self) -> None: filepath = Path("/exports/track.wav") - success = ExportSuccess(kind=ExportKind.WAV, filepath=filepath, truncation=None) + success = ExportSuccess(kind=ExportKind.WAV, filepath=filepath, tracker_format=None, truncation=None) assert success.kind == ExportKind.WAV assert success.filepath == filepath + assert success.tracker_format is None assert success.truncation is None + def test_stores_the_tracker_format(self) -> None: + success = ExportSuccess( + kind=ExportKind.INSTRUMENT, + filepath=Path("/x"), + tracker_format=TrackerFormat.BITPHASE, + truncation=None, + ) + assert success.tracker_format == TrackerFormat.BITPHASE + def test_stores_the_truncation(self) -> None: - truncation = ExportTruncation(frames=252, source_frames=300, instruments=1) - success = ExportSuccess(kind=ExportKind.INSTRUMENT, filepath=Path("/x"), truncation=truncation) + truncation = EnvelopeTruncation(frames=252, source_frames=300, instruments=1) + success = ExportSuccess( + kind=ExportKind.INSTRUMENT, + filepath=Path("/x"), + tracker_format=TrackerFormat.FAMITRACKER, + truncation=truncation, + ) assert success.truncation == truncation def test_frozen(self) -> None: - success = ExportSuccess(kind=ExportKind.WAV, filepath=Path("/x"), truncation=None) + success = ExportSuccess(kind=ExportKind.WAV, filepath=Path("/x"), tracker_format=None, truncation=None) with pytest.raises(FrozenInstanceError): success.kind = ExportKind.INSTRUMENT # type: ignore[misc] def test_equality(self) -> None: path = Path("/x") - assert ExportSuccess(kind=ExportKind.WAV, filepath=path, truncation=None) == ExportSuccess( - kind=ExportKind.WAV, filepath=path, truncation=None + assert ExportSuccess(kind=ExportKind.WAV, filepath=path, tracker_format=None, truncation=None) == ExportSuccess( + kind=ExportKind.WAV, filepath=path, tracker_format=None, truncation=None ) - assert ExportSuccess(kind=ExportKind.WAV, filepath=path, truncation=None) != ExportSuccess( - kind=ExportKind.INSTRUMENT, filepath=path, truncation=None + assert ExportSuccess(kind=ExportKind.WAV, filepath=path, tracker_format=None, truncation=None) != ExportSuccess( + kind=ExportKind.INSTRUMENT, filepath=path, tracker_format=None, truncation=None + ) + + def test_the_tracker_format_separates_two_otherwise_equal_results(self) -> None: + path = Path("/x") + assert ExportSuccess( + kind=ExportKind.INSTRUMENT, filepath=path, tracker_format=TrackerFormat.FAMITRACKER, truncation=None + ) != ExportSuccess( + kind=ExportKind.INSTRUMENT, filepath=path, tracker_format=TrackerFormat.BITPHASE, truncation=None ) class TestExportError: def test_stores_kind_and_exception(self) -> None: exception = OSError("disk full") - error = ExportError(kind=ExportKind.INSTRUMENT, exception=exception) + error = ExportError(kind=ExportKind.INSTRUMENT, tracker_format=TrackerFormat.FAMITRACKER, exception=exception) assert error.kind == ExportKind.INSTRUMENT + assert error.tracker_format == TrackerFormat.FAMITRACKER assert error.exception is exception def test_frozen(self) -> None: - error = ExportError(kind=ExportKind.WAV, exception=OSError()) + error = ExportError(kind=ExportKind.WAV, tracker_format=None, exception=OSError()) with pytest.raises(FrozenInstanceError): - error.kind = ExportKind.INSTRUMENTS # type: ignore[misc] + error.kind = ExportKind.SAMPLE # type: ignore[misc] def test_eq_false_same_exception_instances_differ(self) -> None: exception = OSError("same") - error_a = ExportError(kind=ExportKind.WAV, exception=exception) - error_b = ExportError(kind=ExportKind.WAV, exception=exception) + error_a = ExportError(kind=ExportKind.WAV, tracker_format=None, exception=exception) + error_b = ExportError(kind=ExportKind.WAV, tracker_format=None, exception=exception) assert error_a != error_b def test_same_instance_equals_itself(self) -> None: - error = ExportError(kind=ExportKind.WAV, exception=OSError()) + error = ExportError(kind=ExportKind.WAV, tracker_format=None, exception=OSError()) assert error == error # noqa: PLR0124 diff --git a/tests/unit/sampletones_application/services/export/test_service.py b/tests/unit/sampletones_application/services/export/test_service.py index fc1dca53..ebde20c2 100644 --- a/tests/unit/sampletones_application/services/export/test_service.py +++ b/tests/unit/sampletones_application/services/export/test_service.py @@ -1,6 +1,6 @@ from pathlib import Path -from typing import Any, List -from unittest.mock import MagicMock, call, patch +from typing import Any, Final, List, Optional, Tuple +from unittest.mock import patch import numpy as np import pytest @@ -9,7 +9,92 @@ from sampletones_application.services.export.kind import ExportKind from sampletones_application.services.export.service import ExportService from sampletones_application.services.export.success import ExportSuccess -from sampletones_core.famitracker.sequences.truncation import SequenceTruncation +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.exporters import Features +from sampletones_core.exporters.truncation import EnvelopeTruncation +from sampletones_core.project.project import Project +from sampletones_core.trackers.artifact import ExportArtifact +from sampletones_core.trackers.format import TrackerFormat +from sampletones_core.trackers.request import ( + InstrumentExport, + ProjectExport, + SampleExport, +) +from sampletones_core.trackers.scope import ExportScope + +NES_FREQUENCY: Final[int] = 60 + + +class StubBackend: + """Records what the service asked for and returns a prepared artefact. + + The service under test owns the thread boundary and the result contract; what lands + on disk belongs to the real backends and is exercised in their own tests. + """ + + def __init__( + self, + truncation: Optional[EnvelopeTruncation] = None, + exception: Optional[Exception] = None, + ) -> None: + self.truncation = truncation + self.exception = exception + self.calls: List[Tuple[str, Path, Any]] = [] + + @property + def tracker_format(self) -> TrackerFormat: + return TrackerFormat.FAMITRACKER + + @property + def supported_scopes(self) -> frozenset: + return frozenset(ExportScope) + + def extension(self, scope: ExportScope) -> str: + return ".fti" + + def write_instrument(self, destination: Path, request: InstrumentExport) -> ExportArtifact: + return self._write("instrument", destination, request) + + def write_sample(self, destination: Path, request: SampleExport) -> ExportArtifact: + return self._write("sample", destination, request) + + def write_project(self, destination: Path, request: ProjectExport) -> ExportArtifact: + return self._write("project", destination, request) + + def _write(self, scope: str, destination: Path, request: Any) -> ExportArtifact: + self.calls.append((scope, destination, request)) + if self.exception is not None: + raise self.exception + return ExportArtifact(paths=(destination,), truncation=self.truncation) + + +def build_instrument(name: str = "Lead") -> InstrumentExport: + return InstrumentExport( + name=name, + generator=GeneratorName.PULSE1, + features=Features( + initial_pitch=60, + volume=np.full(8, 15, dtype=int), + arpeggio=np.zeros(8, dtype=int), + pitch=None, + hi_pitch=None, + duty_cycle=None, + ), + loop=False, + nes_frequency=NES_FREQUENCY, + ) + + +def build_sample(count: int = 2) -> SampleExport: + return SampleExport( + name="Kick", + instruments=tuple(build_instrument(f"Kick {index}") for index in range(count)), + nes_frequency=NES_FREQUENCY, + ) + + +def build_project() -> ProjectExport: + return ProjectExport(project=Project.create(title="Song")) @pytest.fixture @@ -20,13 +105,6 @@ def service(): return export_service, results -def feature_mock(truncation: Any = None) -> MagicMock: - """A feature whose save reports the frames a FamiTracker sequence left out.""" - feature = MagicMock() - feature.save.return_value = truncation - return feature - - class TestExportWav: def test_success_emits_export_success(self, service, tmp_path) -> None: export_service, results = service @@ -81,9 +159,8 @@ class TestExportInstrument: def test_success_emits_export_success(self, service, tmp_path) -> None: export_service, results = service filepath = tmp_path / "instrument.fti" - feature = feature_mock() - export_service.export_instrument(filepath, "guitar", feature) + export_service.export_instrument(filepath, StubBackend(), build_instrument()) assert len(results) == 1 result = results[0] @@ -91,23 +168,25 @@ def test_success_emits_export_success(self, service, tmp_path) -> None: assert result.kind == ExportKind.INSTRUMENT assert result.filepath == filepath - def test_success_calls_feature_save(self, service, tmp_path) -> None: + def test_the_backend_receives_the_destination_and_the_request(self, service, tmp_path) -> None: export_service, _ = service filepath = tmp_path / "instrument.fti" - feature = feature_mock() + backend = StubBackend() + request = build_instrument("Guitar") - export_service.export_instrument(filepath, "guitar", feature) + export_service.export_instrument(filepath, backend, request) - feature.save.assert_called_once_with(filepath, "guitar") + assert backend.calls == [("instrument", filepath, request)] def test_error_emits_export_error(self, service, tmp_path) -> None: export_service, results = service - filepath = tmp_path / "instrument.fti" exception = PermissionError("read-only") - feature = feature_mock() - feature.save.side_effect = exception - export_service.export_instrument(filepath, "bass", feature) + export_service.export_instrument( + tmp_path / "instrument.fti", + StubBackend(exception=exception), + build_instrument(), + ) assert len(results) == 1 result = results[0] @@ -117,133 +196,150 @@ def test_error_emits_export_error(self, service, tmp_path) -> None: def test_error_does_not_emit_success(self, service, tmp_path) -> None: export_service, results = service - feature = feature_mock() - feature.save.side_effect = OSError("fail") - export_service.export_instrument(tmp_path / "x.fti", "piano", feature) + export_service.export_instrument( + tmp_path / "x.fti", + StubBackend(exception=OSError("fail")), + build_instrument(), + ) assert not any(isinstance(r, ExportSuccess) for r in results) -class TestExportInstruments: - def test_success_calls_save_for_each_export(self, service, tmp_path) -> None: +class TestExportSample: + def test_success_emits_export_success_with_the_destination(self, service, tmp_path) -> None: + export_service, results = service + + export_service.export_sample(tmp_path, StubBackend(), build_sample()) + + assert len(results) == 1 + result = results[0] + assert isinstance(result, ExportSuccess) + assert result.kind == ExportKind.SAMPLE + assert result.filepath == tmp_path + + def test_the_backend_receives_every_slice_in_one_call(self, service, tmp_path) -> None: export_service, _ = service - features = [feature_mock(), feature_mock(), feature_mock()] - exports = [(tmp_path / f"inst_{i}.fti", f"inst_{i}", features[i]) for i in range(3)] + backend = StubBackend() + request = build_sample(3) + + export_service.export_sample(tmp_path, backend, request) + + assert backend.calls == [("sample", tmp_path, request)] + + def test_error_emits_export_error(self, service, tmp_path) -> None: + export_service, results = service + exception = OSError("no space") + + export_service.export_sample(tmp_path, StubBackend(exception=exception), build_sample()) + + assert len(results) == 1 + result = results[0] + assert isinstance(result, ExportError) + assert result.kind == ExportKind.SAMPLE + assert result.exception is exception - export_service.export_instruments(tmp_path, exports) + def test_a_sample_with_no_slices_emits_success(self, service, tmp_path) -> None: + export_service, results = service + + export_service.export_sample(tmp_path, StubBackend(), build_sample(0)) + + assert len(results) == 1 + assert isinstance(results[0], ExportSuccess) + assert results[0].kind == ExportKind.SAMPLE - for feature in features: - feature.save.assert_called_once() - def test_success_emits_export_success_with_directory(self, service, tmp_path) -> None: +class TestExportProject: + def test_success_emits_export_success(self, service, tmp_path) -> None: export_service, results = service - feature = feature_mock() - exports = [(tmp_path / "inst.fti", "inst", feature)] + filepath = tmp_path / "song.ftm" - export_service.export_instruments(tmp_path, exports) + export_service.export_project(filepath, StubBackend(), build_project()) assert len(results) == 1 result = results[0] assert isinstance(result, ExportSuccess) - assert result.kind == ExportKind.INSTRUMENTS - assert result.filepath == tmp_path + assert result.kind == ExportKind.PROJECT + assert result.filepath == filepath - def test_success_creates_directory(self, service, tmp_path) -> None: + def test_the_backend_receives_the_destination_and_the_request(self, service, tmp_path) -> None: export_service, _ = service - new_dir = tmp_path / "subdir" - feature = feature_mock() - exports = [(new_dir / "inst.fti", "inst", feature)] + filepath = tmp_path / "song.ftm" + backend = StubBackend() + request = build_project() - export_service.export_instruments(new_dir, exports) + export_service.export_project(filepath, backend, request) - assert new_dir.exists() + assert backend.calls == [("project", filepath, request)] - def test_error_on_first_save_emits_export_error(self, service, tmp_path) -> None: + def test_error_emits_export_error(self, service, tmp_path) -> None: export_service, results = service exception = OSError("no space") - first_feature = feature_mock() - first_feature.save.side_effect = exception - second_feature = feature_mock() - exports = [ - (tmp_path / "first.fti", "first", first_feature), - (tmp_path / "second.fti", "second", second_feature), - ] - export_service.export_instruments(tmp_path, exports) + export_service.export_project( + tmp_path / "song.ftm", + StubBackend(exception=exception), + build_project(), + ) assert len(results) == 1 result = results[0] assert isinstance(result, ExportError) - assert result.kind == ExportKind.INSTRUMENTS + assert result.kind == ExportKind.PROJECT assert result.exception is exception - def test_error_stops_after_first_failure(self, service, tmp_path) -> None: - export_service, _ = service - first_feature = feature_mock() - first_feature.save.side_effect = OSError("fail") - second_feature = feature_mock() - exports = [ - (tmp_path / "first.fti", "first", first_feature), - (tmp_path / "second.fti", "second", second_feature), - ] - export_service.export_instruments(tmp_path, exports) +class TestExportFormatReporting: + def test_a_tracker_export_names_the_format_it_was_written_in(self, service, tmp_path) -> None: + export_service, results = service + + export_service.export_instrument(tmp_path / "inst.fti", StubBackend(), build_instrument()) - second_feature.save.assert_not_called() + assert results[0].tracker_format == TrackerFormat.FAMITRACKER - def test_empty_exports_list_emits_success(self, service, tmp_path) -> None: + def test_a_failed_tracker_export_names_the_format_it_was_written_in(self, service, tmp_path) -> None: export_service, results = service - export_service.export_instruments(tmp_path, []) + export_service.export_sample(tmp_path, StubBackend(exception=OSError("fail")), build_sample()) - assert len(results) == 1 - assert isinstance(results[0], ExportSuccess) - assert results[0].kind == ExportKind.INSTRUMENTS + assert results[0].tracker_format == TrackerFormat.FAMITRACKER + + def test_a_wav_export_names_no_format(self, service, tmp_path) -> None: + export_service, results = service + + with patch("sampletones_application.services.export.service.write_wave"): + export_service.export_wav(tmp_path / "track.wav", 44100, np.zeros(100)) + + assert results[0].tracker_format is None class TestExportTruncationReporting: def test_a_complete_instrument_reports_no_truncation(self, service, tmp_path) -> None: export_service, results = service - export_service.export_instrument(tmp_path / "inst.fti", "inst", feature_mock()) + export_service.export_instrument(tmp_path / "inst.fti", StubBackend(), build_instrument()) assert results[0].truncation is None - def test_a_shortened_instrument_reports_its_frames(self, service, tmp_path) -> None: + def test_a_shortened_instrument_carries_the_backend_report(self, service, tmp_path) -> None: export_service, results = service - feature = feature_mock(SequenceTruncation(frames=252, source_frames=300)) + truncation = EnvelopeTruncation(frames=252, source_frames=300, instruments=1) - export_service.export_instrument(tmp_path / "inst.fti", "inst", feature) + export_service.export_instrument( + tmp_path / "inst.fti", + StubBackend(truncation=truncation), + build_instrument(), + ) - truncation = results[0].truncation - assert truncation.frames == 252 - assert truncation.source_frames == 300 - assert truncation.instruments == 1 + assert results[0].truncation == truncation - def test_a_complete_reconstruction_reports_no_truncation(self, service, tmp_path) -> None: + def test_a_shortened_sample_carries_the_backend_report(self, service, tmp_path) -> None: export_service, results = service - exports = [(tmp_path / f"inst_{index}.fti", f"inst_{index}", feature_mock()) for index in range(3)] + truncation = EnvelopeTruncation(frames=252, source_frames=410, instruments=2) - export_service.export_instruments(tmp_path, exports) + export_service.export_sample(tmp_path, StubBackend(truncation=truncation), build_sample(3)) - assert results[0].truncation is None - - def test_a_partly_shortened_reconstruction_counts_the_shortened_instruments(self, service, tmp_path) -> None: - export_service, results = service - features = [ - feature_mock(), - feature_mock(SequenceTruncation(frames=252, source_frames=300)), - feature_mock(SequenceTruncation(frames=252, source_frames=410)), - ] - exports = [(tmp_path / f"inst_{index}.fti", f"inst_{index}", feature) for index, feature in enumerate(features)] - - export_service.export_instruments(tmp_path, exports) - - truncation = results[0].truncation - assert truncation.instruments == 2 - assert truncation.frames == 252 - assert truncation.source_frames == 410 + assert results[0].truncation == truncation def test_a_wav_export_reports_no_truncation(self, service, tmp_path) -> None: export_service, results = service diff --git a/tests/unit/sampletones_application/services/export/test_truncation.py b/tests/unit/sampletones_application/services/export/test_truncation.py deleted file mode 100644 index a34314d9..00000000 --- a/tests/unit/sampletones_application/services/export/test_truncation.py +++ /dev/null @@ -1,20 +0,0 @@ -from sampletones_application.services.export.truncation import ExportTruncation -from sampletones_core.famitracker.sequences.truncation import SequenceTruncation - - -class TestExportTruncationSummarize: - def test_a_complete_export_summarizes_to_nothing(self) -> None: - assert ExportTruncation.summarize([None, None]) is None - - def test_an_empty_export_summarizes_to_nothing(self) -> None: - assert ExportTruncation.summarize([]) is None - - def test_the_summary_spans_every_shortened_instrument(self) -> None: - summary = ExportTruncation.summarize( - [ - None, - SequenceTruncation(frames=252, source_frames=300), - SequenceTruncation(frames=252, source_frames=480), - ] - ) - assert summary == ExportTruncation(frames=252, source_frames=480, instruments=2) diff --git a/tests/unit/sampletones_application/services/test_conversion.py b/tests/unit/sampletones_application/services/test_conversion.py index f3f6e16b..d9f0ee02 100644 --- a/tests/unit/sampletones_application/services/test_conversion.py +++ b/tests/unit/sampletones_application/services/test_conversion.py @@ -15,6 +15,7 @@ ServiceSuccess, ) from sampletones_core.parallelization import TaskProgress, TaskStatus +from sampletones_shared.types.data import SerializedData @pytest.fixture diff --git a/tests/unit/sampletones_application/services/test_regeneration.py b/tests/unit/sampletones_application/services/test_regeneration.py index 79507dff..57be1d74 100644 --- a/tests/unit/sampletones_application/services/test_regeneration.py +++ b/tests/unit/sampletones_application/services/test_regeneration.py @@ -1,6 +1,6 @@ import threading from types import SimpleNamespace -from typing import Any, Dict, List +from typing import Any, Dict, Final, List from unittest.mock import MagicMock, patch import numpy as np @@ -10,6 +10,31 @@ from sampletones_application.services.result import ServiceCancelled, ServiceError, ServiceSuccess from sampletones_core.constants.enums import FeatureKey, GeneratorName +REFERENCE_PITCH: Final[int] = 60 + + +class FakeFeatures(Dict[Any, Any]): + """Stands in for ``Features``: records the edited dimension and carries a reference pitch. + + Assigning ``FeatureKey.INITIAL_PITCH`` moves the reference pitch, matching the real model, + so the pitch stepper's edit is observable through ``initial_pitch``. + """ + + def __init__(self, initial_pitch: int) -> None: + super().__init__() + self.initial_pitch = initial_pitch + + def __setitem__(self, feature_key: Any, value: Any) -> None: + if feature_key == FeatureKey.INITIAL_PITCH: + self.initial_pitch = value + else: + super().__setitem__(feature_key, value) + + +@pytest.fixture +def features() -> FakeFeatures: + return FakeFeatures(REFERENCE_PITCH) + @pytest.fixture def synthesis_mocks(): @@ -119,7 +144,7 @@ def test_run_when_cancelled_emits_service_cancelled(self) -> None: assert len(results) == 1 assert isinstance(results[0], ServiceCancelled) - def test_run_success_emits_service_success(self, synthesis_mocks, reconstruction) -> None: + def test_run_success_emits_service_success(self, synthesis_mocks, reconstruction, features) -> None: service = RegenerationService() results: List[Any] = [] service.subscribe(results.append) @@ -127,7 +152,7 @@ def test_run_success_emits_service_success(self, synthesis_mocks, reconstruction service._run( reconstruction, synthesis_mocks.generator_name, - {}, + features, FeatureKey.VOLUME, 1, ) @@ -140,9 +165,8 @@ def test_run_success_emits_service_success(self, synthesis_mocks, reconstruction assert outcome.generator_name is synthesis_mocks.generator_name assert outcome.feature_key is FeatureKey.VOLUME - def test_run_updates_feature_before_synthesis(self, synthesis_mocks, reconstruction) -> None: + def test_run_updates_feature_before_synthesis(self, synthesis_mocks, reconstruction, features) -> None: service = RegenerationService() - features: Dict[Any, Any] = {} feature_key = FeatureKey.VOLUME new_value = 42 @@ -156,13 +180,13 @@ def test_run_updates_feature_before_synthesis(self, synthesis_mocks, reconstruct assert features[feature_key] == new_value - def test_run_updates_reconstruction_copy(self, synthesis_mocks, reconstruction) -> None: + def test_run_updates_reconstruction_copy(self, synthesis_mocks, reconstruction, features) -> None: service = RegenerationService() service._run( reconstruction, synthesis_mocks.generator_name, - {}, + features, FeatureKey.VOLUME, 1, ) @@ -173,7 +197,44 @@ def test_run_updates_reconstruction_copy(self, synthesis_mocks, reconstruction) call_args = updated.update_generator_data.call_args assert call_args.args[0] == synthesis_mocks.generator_name - def test_run_calls_generator_for_each_instruction(self, synthesis_mocks, reconstruction) -> None: + def test_run_carries_the_reference_pitch_through_an_arpeggio_edit( + self, synthesis_mocks, reconstruction, features + ) -> None: + """An arpeggio edit stores the reference pitch the edit was made from. + + Handing the unchanged reference back to the reconstruction is what keeps a later + export measuring the envelope against the same base. + """ + service = RegenerationService() + + service._run( + reconstruction, + synthesis_mocks.generator_name, + features, + FeatureKey.ARPEGGIO, + np.array([12, 0], dtype=np.int8), + ) + + call_args = reconstruction.model_copy.return_value.update_generator_data.call_args + assert call_args.args[3] == REFERENCE_PITCH + + def test_run_carries_a_moved_reference_pitch(self, synthesis_mocks, reconstruction, features) -> None: + """The pitch stepper's edit stores the new reference pitch.""" + moved_pitch = REFERENCE_PITCH + 12 + service = RegenerationService() + + service._run( + reconstruction, + synthesis_mocks.generator_name, + features, + FeatureKey.INITIAL_PITCH, + moved_pitch, + ) + + call_args = reconstruction.model_copy.return_value.update_generator_data.call_args + assert call_args.args[3] == moved_pitch + + def test_run_calls_generator_for_each_instruction(self, synthesis_mocks, reconstruction, features) -> None: extra_instruction = MagicMock() synthesis_mocks.exporter.from_features.return_value = [synthesis_mocks.instruction, extra_instruction] service = RegenerationService() @@ -181,7 +242,7 @@ def test_run_calls_generator_for_each_instruction(self, synthesis_mocks, reconst service._run( reconstruction, synthesis_mocks.generator_name, - {}, + features, FeatureKey.VOLUME, 1, ) @@ -241,7 +302,7 @@ class TestRegenerationServiceCancellationConstraints: synthesis that is already in progress. """ - def test_cancel_while_running_does_not_interrupt_synthesis(self, synthesis_mocks) -> None: + def test_cancel_while_running_does_not_interrupt_synthesis(self, synthesis_mocks, features) -> None: service = RegenerationService() results: List[Any] = [] done = threading.Event() @@ -255,7 +316,7 @@ def on_result(result: Any) -> None: task_started = threading.Event() task_unblock = threading.Event() - def blocking_from_features(features): + def blocking_from_features(edited_features): task_started.set() task_unblock.wait(timeout=2.0) return [synthesis_mocks.instruction] @@ -265,7 +326,13 @@ def blocking_from_features(features): reconstruction.config = MagicMock() thread = threading.Thread( - target=lambda: service._run(reconstruction, synthesis_mocks.generator_name, {}, FeatureKey.VOLUME, 1), + target=lambda: service._run( + reconstruction, + synthesis_mocks.generator_name, + features, + FeatureKey.VOLUME, + 1, + ), ) thread.start() task_started.wait(timeout=2.0) diff --git a/tests/unit/sampletones_application/ui/panels/reconstruction/test_instruments_panel.py b/tests/unit/sampletones_application/ui/panels/reconstruction/test_instruments_panel.py index e0f9c0b3..79e7e693 100644 --- a/tests/unit/sampletones_application/ui/panels/reconstruction/test_instruments_panel.py +++ b/tests/unit/sampletones_application/ui/panels/reconstruction/test_instruments_panel.py @@ -26,7 +26,7 @@ from sampletones_application.ui.themes.theme import Theme from sampletones_application.utils.palette import Palette from sampletones_core.constants.enums import FeatureKey, GeneratorName -from sampletones_core.famitracker.specification.sequences import MAX_SEQUENCE_ITEMS +from sampletones_core.formats.famitracker.specification.sequences import MAX_SEQUENCE_ITEMS @pytest.fixture @@ -107,6 +107,45 @@ def test_each_dimension_carries_its_own_length( assert bound_themes == [TAG_GLOBAL_THEME_INPUT_WARNING, TAG_GLOBAL_THEME_DEFAULT] +class TestInstrumentExport: + """The export button carries the generator whose slice it writes; the destination the + dialog answers with names the tracker, so no format travels from here.""" + + def test_the_generator_reaches_the_export_callback( + self, + panel: GUIReconstructionInstrumentsPanel, + ) -> None: + calls: List[GeneratorName] = [] + panel.on_instrument_export = calls.append + + panel._export_callback(GeneratorName.NOISE)() + + assert calls == [GeneratorName.NOISE] + + def test_each_generator_gets_its_own_handler( + self, + panel: GUIReconstructionInstrumentsPanel, + ) -> None: + calls: List[GeneratorName] = [] + panel.on_instrument_export = calls.append + + for generator_name in GeneratorName.items(): + panel._export_callback(generator_name)() + + assert calls == list(GeneratorName.items()) + + def test_the_handler_is_one_the_framework_can_dispatch( + self, + panel: GUIReconstructionInstrumentsPanel, + ) -> None: + """DearPyGui reads a callback's ``__code__`` to decide how many arguments to pass it, + so a press handler carries one and takes the arguments the framework offers a button. + """ + callback = panel._export_callback(GeneratorName.NOISE) + + assert callback.__code__.co_argcount == 0 + + class TestSequenceStatusMessage: def test_a_sequence_within_the_limit_describes_editing( self, diff --git a/tests/unit/sampletones_application/ui/test_menu.py b/tests/unit/sampletones_application/ui/test_menu.py index e00e72e0..d26dbe00 100644 --- a/tests/unit/sampletones_application/ui/test_menu.py +++ b/tests/unit/sampletones_application/ui/test_menu.py @@ -7,10 +7,15 @@ from sampletones_application.paths import LANG_EN from sampletones_application.tags.general import ( TAG_GLOBAL_MENU_ITEM_PLAYBACK_UNMUTE_ALL_CHANNELS, + TAG_GLOBAL_MENU_ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS, ) from sampletones_application.ui import menu as menu_module from sampletones_application.ui.menu import MenuBar -from sampletones_application.utils.gui.shortcuts.ids import CHANNEL_SHORTCUT_IDS, ShortcutId +from sampletones_application.utils.gui.shortcuts.ids import ( + CHANNEL_SHORTCUT_IDS, + SAMPLE_EXPORT_SHORTCUT_IDS, + ShortcutId, +) from sampletones_application.view_model.sequencer.channels import SequencerChannelsViewModel from sampletones_application.view_model.shared.menu import MenuBarViewModel from sampletones_core.constants.enums import GeneratorName @@ -42,11 +47,16 @@ class _DearPyGuiRecorder: def __init__(self) -> None: self.values: Dict[str, bool] = {} self.enabled: Dict[str, bool] = {} + self.menus: List[Dict[str, Any]] = [] @contextmanager def menu(self, **kwargs: Any) -> Iterator[int]: + self.menus.append(kwargs) yield 0 + def submenu(self, tag: str) -> Dict[str, Any]: + return next(entry for entry in self.menus if entry.get("tag") == tag) + def add_separator(self, **kwargs: Any) -> int: return 0 @@ -57,10 +67,14 @@ def configure_item(self, item: str, **kwargs: Any) -> None: self.enabled[item] = kwargs["enabled"] -def _state(muted: FrozenSet[GeneratorName]) -> MenuBarViewModel: +def _state( + muted: FrozenSet[GeneratorName], + *, + reconstruction_loaded: bool = False, +) -> MenuBarViewModel: return MenuBarViewModel( project_open=True, - reconstruction_loaded=False, + reconstruction_loaded=reconstruction_loaded, reconstruction_saveable=False, reconstruction_in_project=False, reconstruction_file_backed=False, @@ -107,6 +121,46 @@ def menu_bar(shortcuts: _ShortcutManagerRecorder) -> MenuBar: return instance +class TestInstrumentsExportMenu: + """Each tracker that writes a file per slice gets its own item, so choosing the tracker + is one click and the destination dialog then offers that tracker's type alone.""" + + def test_every_offered_tracker_is_listed( + self, + menu_bar: MenuBar, + framework: _DearPyGuiRecorder, + shortcuts: _ShortcutManagerRecorder, + ) -> None: + menu_bar._create_reconstruction_menu(_state(frozenset())) + + entries = [item for item in shortcuts.items if item["shortcut_id"] in SAMPLE_EXPORT_SHORTCUT_IDS.values()] + + assert [entry["label"] for entry in entries] == [ + "FamiTracker instruments...", + "Bitphase presets...", + ] + + def test_the_submenu_waits_for_a_loaded_reconstruction( + self, + menu_bar: MenuBar, + framework: _DearPyGuiRecorder, + shortcuts: _ShortcutManagerRecorder, + ) -> None: + menu_bar._create_reconstruction_menu(_state(frozenset())) + + assert framework.submenu(TAG_GLOBAL_MENU_ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS)["enabled"] is False + + def test_the_submenu_is_offered_once_a_reconstruction_is_loaded( + self, + menu_bar: MenuBar, + framework: _DearPyGuiRecorder, + shortcuts: _ShortcutManagerRecorder, + ) -> None: + menu_bar._create_reconstruction_menu(_state(frozenset(), reconstruction_loaded=True)) + + assert framework.submenu(TAG_GLOBAL_MENU_ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS)["enabled"] is True + + class TestChannelsMenuItems: def test_every_channel_is_named_in_the_tracker_order( self, diff --git a/tests/unit/sampletones_application/utils/file_dialogs/backends/__init__.py b/tests/unit/sampletones_application/utils/file_dialogs/backends/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/sampletones_application/utils/file_dialogs/backends/portal/__init__.py b/tests/unit/sampletones_application/utils/file_dialogs/backends/portal/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/sampletones_application/utils/file_dialogs/backends/portal/test_backend.py b/tests/unit/sampletones_application/utils/file_dialogs/backends/portal/test_backend.py new file mode 100644 index 00000000..9112fe24 --- /dev/null +++ b/tests/unit/sampletones_application/utils/file_dialogs/backends/portal/test_backend.py @@ -0,0 +1,209 @@ +from pathlib import Path +from typing import Dict, Final, List, Optional, Tuple + +import pytest + +from sampletones_application.utils.file_dialogs.backends.portal.backend import ( + CURRENT_FILTER_OPTION, + CURRENT_FOLDER_OPTION, + CURRENT_NAME_OPTION, + DIRECTORY_OPTION, + FILTERS_OPTION, + MINIMUM_FILE_CHOOSER_VERSION, + PortalBackend, +) +from sampletones_application.utils.file_dialogs.backends.portal.response import ChooserResult +from sampletones_application.utils.file_dialogs.backends.portal.variant import Variant +from sampletones_application.utils.file_dialogs.destination import SaveDestination +from sampletones_application.utils.file_dialogs.filter import FileFilter + +FAMITRACKER_FILTER: Final[FileFilter] = FileFilter(name="FamiTracker instrument", patterns=("*.fti",)) +PRESET_FILTER: Final[FileFilter] = FileFilter(name="Bitphase instrument preset", patterns=("*.json",)) +INSTRUMENT_FILTERS: Final[Tuple[FileFilter, ...]] = (FAMITRACKER_FILTER, PRESET_FILTER) + +HOME: Final[Path] = Path("/home/user") + + +class FakeClient: + """A portal answering with one prepared result, recording what it was asked to show.""" + + def __init__( + self, + result: Optional[ChooserResult], + version: Optional[int] = MINIMUM_FILE_CHOOSER_VERSION, + ) -> None: + self._result = result + self._version = version + self.calls: List[Tuple[str, str, Dict[str, Variant]]] = [] + + def version(self) -> Optional[int]: + return self._version + + def call( + self, + *, + method: str, + title: str, + options: Dict[str, Variant], + ) -> Optional[ChooserResult]: + self.calls.append((method, title, options)) + return self._result + + +def _saved( + uri: str, + label: Optional[str], +) -> ChooserResult: + return ChooserResult(uris=(uri,), filter_label=label) + + +class TestPortalBackendSave: + def test_options_carry_the_types_the_name_and_the_folder(self) -> None: + client = FakeClient(_saved("file:///home/user/kick.fti", FAMITRACKER_FILTER.label)) + backend = PortalBackend(client) + + backend.save_file( + title="Export instrument", + initial_directory=HOME, + suggested_name="Kick (pulse1)", + filters=INSTRUMENT_FILTERS, + ) + + method, title, options = client.calls[0] + assert (method, title) == ("SaveFile", "Export instrument") + assert options[FILTERS_OPTION] == ( + "a(sa(us))", + [ + ("FamiTracker instrument (*.fti)", [(0, "*.fti")]), + ("Bitphase instrument preset (*.json)", [(0, "*.json")]), + ], + ) + assert options[CURRENT_NAME_OPTION] == ("s", "Kick (pulse1)") + assert options[CURRENT_FOLDER_OPTION] == ("ay", b"/home/user\x00") + + def test_the_dialog_opens_on_the_first_offered_type(self) -> None: + client = FakeClient(_saved("file:///home/user/kick.fti", FAMITRACKER_FILTER.label)) + backend = PortalBackend(client) + + backend.save_file( + title="Export instrument", + initial_directory=None, + suggested_name=None, + filters=INSTRUMENT_FILTERS, + ) + + options = client.calls[0][2] + assert options[CURRENT_FILTER_OPTION] == ("(sa(us))", ("FamiTracker instrument (*.fti)", [(0, "*.fti")])) + assert CURRENT_NAME_OPTION not in options + assert CURRENT_FOLDER_OPTION not in options + + def test_the_reported_label_names_the_offered_type(self) -> None: + client = FakeClient(_saved("file:///home/user/kick", PRESET_FILTER.label)) + backend = PortalBackend(client) + + destination = backend.save_file( + title="Export instrument", + initial_directory=HOME, + suggested_name="kick", + filters=INSTRUMENT_FILTERS, + ) + + assert destination == SaveDestination(path=Path("/home/user/kick"), file_type=PRESET_FILTER) + + def test_an_unreported_type_leaves_the_destination_typeless(self) -> None: + client = FakeClient(_saved("file:///home/user/kick.fti", None)) + backend = PortalBackend(client) + + destination = backend.save_file( + title="Export instrument", + initial_directory=HOME, + suggested_name="kick", + filters=INSTRUMENT_FILTERS, + ) + + assert destination == SaveDestination(path=Path("/home/user/kick.fti"), file_type=None) + + def test_a_dismissed_dialog_answers_with_nothing(self) -> None: + client = FakeClient(None) + backend = PortalBackend(client) + + destination = backend.save_file( + title="Export instrument", + initial_directory=HOME, + suggested_name="kick", + filters=INSTRUMENT_FILTERS, + ) + + assert destination is None + + +class TestPortalBackendOpen: + def test_an_escaped_uri_reads_as_the_path_it_names(self) -> None: + client = FakeClient(_saved("file:///home/user/Kick%20%28pulse1%29.fti", FAMITRACKER_FILTER.label)) + backend = PortalBackend(client) + + filepath = backend.open_file( + title="Open instrument", + initial_directory=HOME, + filters=INSTRUMENT_FILTERS, + ) + + assert filepath == Path("/home/user/Kick (pulse1).fti") + assert client.calls[0][0] == "OpenFile" + + def test_a_location_outside_the_file_system_answers_with_nothing(self) -> None: + client = FakeClient(_saved("https://example.invalid/kick.fti", None)) + backend = PortalBackend(client) + + filepath = backend.open_file( + title="Open instrument", + initial_directory=HOME, + filters=INSTRUMENT_FILTERS, + ) + + assert filepath is None + + def test_no_offered_types_leaves_the_selector_out(self) -> None: + client = FakeClient(_saved("file:///home/user/kick.fti", None)) + backend = PortalBackend(client) + + backend.open_file( + title="Open instrument", + initial_directory=HOME, + filters=(), + ) + + options = client.calls[0][2] + assert FILTERS_OPTION not in options + assert CURRENT_FILTER_OPTION not in options + + +class TestPortalBackendSelectDirectory: + def test_the_dialog_is_asked_for_a_folder(self) -> None: + client = FakeClient(_saved("file:///home/user/instruments", None)) + backend = PortalBackend(client) + + directory = backend.select_directory(title="Choose folder", initial_directory=HOME) + + method, _title, options = client.calls[0] + assert directory == Path("/home/user/instruments") + assert method == "OpenFile" + assert options[DIRECTORY_OPTION] == ("b", True) + + +class TestPortalAvailability: + @pytest.mark.parametrize("version", [None, MINIMUM_FILE_CHOOSER_VERSION - 1]) + def test_a_portal_below_the_needed_version_leaves_dialogs_to_another_backend( + self, + version: Optional[int], + ) -> None: + from sampletones_application.utils.file_dialogs.backends.portal import backend as backend_module + + client = FakeClient(None, version=version) + with pytest.MonkeyPatch.context() as patcher: + patcher.setattr(backend_module, "FileChooserClient", lambda: client) + backend_module.portal_backend.cache_clear() + try: + assert backend_module.portal_backend() is None + finally: + backend_module.portal_backend.cache_clear() diff --git a/tests/unit/sampletones_application/utils/file_dialogs/backends/portal/test_client.py b/tests/unit/sampletones_application/utils/file_dialogs/backends/portal/test_client.py new file mode 100644 index 00000000..7342d00e --- /dev/null +++ b/tests/unit/sampletones_application/utils/file_dialogs/backends/portal/test_client.py @@ -0,0 +1,249 @@ +from collections import deque +from contextlib import contextmanager +from types import SimpleNamespace +from typing import Deque, Dict, Final, Iterator, List, Optional, Tuple, cast + +import pytest +from jeepney import HeaderFields, MatchRule, MessageType + +from sampletones_application.utils.file_dialogs.backends.portal import client as client_module +from sampletones_application.utils.file_dialogs.backends.portal.client import ( + NAME_OWNER_CHANGED_SIGNAL, + NO_OWNER, + PORTAL_BUS_NAME, + RESPONSE_SIGNAL, + FileChooserClient, +) +from sampletones_application.utils.file_dialogs.backends.portal.response import ChooserResult +from sampletones_application.utils.file_dialogs.backends.portal.variant import Variant + +HANDLE: Final[str] = "/org/freedesktop/portal/desktop/request/1_42/sampletones" +OTHER_HANDLE: Final[str] = "/org/freedesktop/portal/desktop/request/1_7/elsewhere" +LABEL: Final[str] = "Bitphase instrument preset (*.json)" +PORTAL_OWNER: Final[str] = ":1.42" +PARENT_WINDOW: Final[str] = "x11:2200132" + + +def _message( + body: Tuple[object, ...], + path: Optional[str] = None, + member: Optional[str] = None, +) -> SimpleNamespace: + fields: Dict[HeaderFields, str] = {} + if path is not None: + fields[HeaderFields.path] = path + if member is not None: + fields[HeaderFields.member] = member + + return SimpleNamespace( + header=SimpleNamespace(fields=fields, message_type=MessageType.method_return), + body=body, + ) + + +def _response( + code: int, + results: Dict[str, Variant], + path: str = HANDLE, +) -> SimpleNamespace: + return _message( + (code, results), + path=path, + member=RESPONSE_SIGNAL, + ) + + +def _name_owner_changed( + previous_owner: str, + current_owner: str, +) -> SimpleNamespace: + return _message( + ( + PORTAL_BUS_NAME, + previous_owner, + current_owner, + ), + member=NAME_OWNER_CHANGED_SIGNAL, + ) + + +class FakeConnection: + """A session bus answering method calls in order and delivering prepared signals.""" + + def __init__( + self, + replies: List[SimpleNamespace], + signals: List[SimpleNamespace], + ) -> None: + self._replies = deque(replies) + self._signals = deque(signals) + self.sent: List[str] = [] + self.bodies: List[Tuple[object, ...]] = [] + self.rules: List[object] = [] + self.closed = False + + def __enter__(self) -> "FakeConnection": + return self + + def __exit__(self, *arguments: object) -> None: + self.closed = True + + @contextmanager + def filter( + self, + rule: object, + *, + queue: Optional[Deque[SimpleNamespace]] = None, + ) -> Iterator[Deque[SimpleNamespace]]: + self.rules.append(rule) + yield self._signals if queue is None else queue + + def send_and_get_reply(self, message: object) -> SimpleNamespace: + member = getattr(message, "header").fields[HeaderFields.member] + self.sent.append(member) + self.bodies.append(getattr(message, "body")) + return self._replies.popleft() + + def recv_until_filtered(self, queue: Deque[SimpleNamespace]) -> SimpleNamespace: + return queue.popleft() + + +def _connecting(connection: FakeConnection) -> object: + def opener(*, bus: str) -> FakeConnection: + assert bus == "SESSION" + return connection + + return opener + + +class TestVersion: + def test_the_portal_reports_the_interface_version(self, monkeypatch: pytest.MonkeyPatch) -> None: + connection = FakeConnection(replies=[_message((("u", 3),))], signals=[]) + monkeypatch.setattr(client_module, "open_dbus_connection", _connecting(connection)) + + assert FileChooserClient().version() == 3 + assert connection.closed + + @pytest.mark.parametrize( + "failure", + [ + KeyError("DBUS_SESSION_BUS_ADDRESS"), + FileNotFoundError("no such socket"), + RuntimeError("unsupported transport"), + ], + ) + def test_a_bus_out_of_reach_leaves_the_version_unknown( + self, + monkeypatch: pytest.MonkeyPatch, + failure: Exception, + ) -> None: + def opener(*, bus: str) -> FakeConnection: + raise failure + + monkeypatch.setattr(client_module, "open_dbus_connection", opener) + + assert FileChooserClient().version() is None + + +class TestCall: + def test_the_response_to_the_open_request_is_the_answer(self, monkeypatch: pytest.MonkeyPatch) -> None: + connection = FakeConnection( + replies=[_message(("ok",)), _message(("ok",)), _message((HANDLE,))], + signals=[ + _response( + 0, + { + "uris": ("as", ["file:///home/user/kick.json"]), + "current_filter": ("(sa(us))", (LABEL, [(0, "*.json")])), + }, + ) + ], + ) + monkeypatch.setattr(client_module, "open_dbus_connection", _connecting(connection)) + + result = FileChooserClient().call(method="SaveFile", title="Export instrument", options={}) + + assert result == ChooserResult(uris=("file:///home/user/kick.json",), filter_label=LABEL) + assert connection.sent == ["AddMatch", "AddMatch", "SaveFile"] + + def test_the_dialog_names_the_application_s_window_as_its_parent(self, monkeypatch: pytest.MonkeyPatch) -> None: + """The window a dialog belongs to is what the portal places it over.""" + connection = FakeConnection( + replies=[_message(("ok",)), _message(("ok",)), _message((HANDLE,))], + signals=[_response(1, {})], + ) + monkeypatch.setattr(client_module, "open_dbus_connection", _connecting(connection)) + monkeypatch.setattr(client_module, "parent_window_handle", lambda: PARENT_WINDOW) + + FileChooserClient().call(method="SaveFile", title="Export instrument", options={}) + + assert connection.bodies[-1] == ( + PARENT_WINDOW, + "Export instrument", + {}, + ) + + def test_another_request_s_response_is_passed_over(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Every portal response on the bus reaches the subscription, so each call waits for its own.""" + connection = FakeConnection( + replies=[_message(("ok",)), _message(("ok",)), _message((HANDLE,))], + signals=[ + _response(0, {"uris": ("as", ["file:///elsewhere/other.json"])}, path=OTHER_HANDLE), + _response(0, {"uris": ("as", ["file:///home/user/kick.json"])}), + ], + ) + monkeypatch.setattr(client_module, "open_dbus_connection", _connecting(connection)) + + result = FileChooserClient().call(method="SaveFile", title="Export instrument", options={}) + + assert result == ChooserResult(uris=("file:///home/user/kick.json",), filter_label=None) + + def test_a_dismissed_dialog_answers_with_nothing(self, monkeypatch: pytest.MonkeyPatch) -> None: + connection = FakeConnection( + replies=[_message(("ok",)), _message(("ok",)), _message((HANDLE,))], + signals=[_response(1, {})], + ) + monkeypatch.setattr(client_module, "open_dbus_connection", _connecting(connection)) + + assert FileChooserClient().call(method="SaveFile", title="Export instrument", options={}) is None + + +class TestAPortalLeavingTheBus: + """The portal owes every open dialog its response, so the bus announcing that name released + is what tells a waiting call the answer is never coming.""" + + def test_the_call_subscribes_to_the_portal_s_name(self, monkeypatch: pytest.MonkeyPatch) -> None: + connection = FakeConnection( + replies=[_message(("ok",)), _message(("ok",)), _message((HANDLE,))], + signals=[_response(1, {})], + ) + monkeypatch.setattr(client_module, "open_dbus_connection", _connecting(connection)) + + FileChooserClient().call(method="SaveFile", title="Export instrument", options={}) + + subscriptions = [cast(MatchRule, rule).serialise() for rule in connection.rules] + assert any(NAME_OWNER_CHANGED_SIGNAL in rule and PORTAL_BUS_NAME in rule for rule in subscriptions) + + def test_the_name_released_ends_the_wait(self, monkeypatch: pytest.MonkeyPatch) -> None: + connection = FakeConnection( + replies=[_message(("ok",)), _message(("ok",)), _message((HANDLE,))], + signals=[_name_owner_changed(PORTAL_OWNER, NO_OWNER)], + ) + monkeypatch.setattr(client_module, "open_dbus_connection", _connecting(connection)) + + assert FileChooserClient().call(method="SaveFile", title="Export instrument", options={}) is None + + def test_the_name_taken_up_leaves_the_dialog_waiting(self, monkeypatch: pytest.MonkeyPatch) -> None: + """A call may be what starts the portal, so the name arriving is the dialog opening.""" + connection = FakeConnection( + replies=[_message(("ok",)), _message(("ok",)), _message((HANDLE,))], + signals=[ + _name_owner_changed(NO_OWNER, PORTAL_OWNER), + _response(0, {"uris": ("as", ["file:///home/user/kick.json"])}), + ], + ) + monkeypatch.setattr(client_module, "open_dbus_connection", _connecting(connection)) + + result = FileChooserClient().call(method="SaveFile", title="Export instrument", options={}) + + assert result == ChooserResult(uris=("file:///home/user/kick.json",), filter_label=None) diff --git a/tests/unit/sampletones_application/utils/file_dialogs/backends/portal/test_parent.py b/tests/unit/sampletones_application/utils/file_dialogs/backends/portal/test_parent.py new file mode 100644 index 00000000..30e035a6 --- /dev/null +++ b/tests/unit/sampletones_application/utils/file_dialogs/backends/portal/test_parent.py @@ -0,0 +1,80 @@ +import os +from typing import Final, List, Optional, Type + +import pytest + +from sampletones_application.utils.file_dialogs.backends.portal import parent as parent_module +from sampletones_application.utils.file_dialogs.backends.portal.parent import ( + NO_PARENT_WINDOW, + parent_window_handle, +) + +WINDOW_ID: Final[int] = 0x2200132 +HANDLE: Final[str] = "x11:2200132" + + +class FakeDisplay: + """An X server answering with one prepared window, recording the lookups and its release.""" + + def __init__(self, window_id: Optional[int]) -> None: + self._window_id = window_id + self.processes: List[int] = [] + self.closed = False + + def window_of_process(self, process_id: int) -> Optional[int]: + self.processes.append(process_id) + return self._window_id + + def close(self) -> None: + self.closed = True + + +def _opening(display: Optional[FakeDisplay]) -> Type[object]: + class FakeX11Display: + @staticmethod + def open() -> Optional[FakeDisplay]: + return display + + return FakeX11Display + + +class TestParentWindowHandle: + def test_the_handle_names_the_window_in_hexadecimal(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(parent_module, "X11Display", _opening(FakeDisplay(WINDOW_ID))) + + assert parent_window_handle() == HANDLE + + def test_the_window_looked_for_is_the_one_this_process_draws_in(self, monkeypatch: pytest.MonkeyPatch) -> None: + display = FakeDisplay(WINDOW_ID) + monkeypatch.setattr(parent_module, "X11Display", _opening(display)) + + parent_window_handle() + + assert display.processes == [os.getpid()] + + def test_the_connection_is_released_once_the_window_is_found(self, monkeypatch: pytest.MonkeyPatch) -> None: + display = FakeDisplay(WINDOW_ID) + monkeypatch.setattr(parent_module, "X11Display", _opening(display)) + + parent_window_handle() + + assert display.closed + + def test_a_desktop_listing_no_window_for_this_process_leaves_the_dialog_on_its_own( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + display = FakeDisplay(None) + monkeypatch.setattr(parent_module, "X11Display", _opening(display)) + + assert parent_window_handle() == NO_PARENT_WINDOW + assert display.closed + + def test_a_session_running_without_x11_leaves_the_dialog_on_its_own( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A display that stays closed is how a session with no X server answers.""" + monkeypatch.setattr(parent_module, "X11Display", _opening(None)) + + assert parent_window_handle() == NO_PARENT_WINDOW diff --git a/tests/unit/sampletones_application/utils/file_dialogs/backends/portal/test_response.py b/tests/unit/sampletones_application/utils/file_dialogs/backends/portal/test_response.py new file mode 100644 index 00000000..605431b3 --- /dev/null +++ b/tests/unit/sampletones_application/utils/file_dialogs/backends/portal/test_response.py @@ -0,0 +1,55 @@ +from types import SimpleNamespace +from typing import Dict, Final + +from sampletones_application.utils.file_dialogs.backends.portal.response import ( + CURRENT_FILTER_RESULT, + SUCCESS_CODE, + URIS_RESULT, + ChooserResult, +) +from sampletones_application.utils.file_dialogs.backends.portal.variant import Variant + +URI: Final[str] = "file:///home/user/kick.json" +OTHER_URI: Final[str] = "file:///home/user/snare.json" +LABEL: Final[str] = "Bitphase instrument preset (*.json)" +DISMISSED_CODE: Final[int] = 1 + +URIS_SIGNATURE: Final[str] = "as" +FILTER_SIGNATURE: Final[str] = "(sa(us))" + + +def _response( + code: int, + results: Dict[str, Variant], +) -> SimpleNamespace: + return SimpleNamespace(body=(code, results)) + + +class TestChooserResult: + def test_the_chosen_locations_arrive_in_the_dialog_s_order(self) -> None: + response = _response(SUCCESS_CODE, {URIS_RESULT: (URIS_SIGNATURE, [URI, OTHER_URI])}) + + assert ChooserResult.from_response(response) == ChooserResult( + uris=(URI, OTHER_URI), + filter_label=None, + ) + + def test_the_reported_filter_names_the_type_the_selector_stood_on(self) -> None: + """The portal reports the whole filter, and its label is what names the type.""" + response = _response( + SUCCESS_CODE, + { + URIS_RESULT: (URIS_SIGNATURE, [URI]), + CURRENT_FILTER_RESULT: (FILTER_SIGNATURE, (LABEL, [(0, "*.json")])), + }, + ) + + assert ChooserResult.from_response(response) == ChooserResult(uris=(URI,), filter_label=LABEL) + + def test_a_dismissal_answers_with_nothing(self) -> None: + assert ChooserResult.from_response(_response(DISMISSED_CODE, {})) is None + + def test_a_response_carrying_no_locations_answers_with_none_chosen(self) -> None: + response = _response(SUCCESS_CODE, {CURRENT_FILTER_RESULT: (FILTER_SIGNATURE, (LABEL, []))}) + + assert ChooserResult.from_response(response) == ChooserResult(uris=(), filter_label=LABEL) diff --git a/tests/unit/sampletones_application/utils/file_dialogs/backends/test_command.py b/tests/unit/sampletones_application/utils/file_dialogs/backends/test_command.py new file mode 100644 index 00000000..1673117c --- /dev/null +++ b/tests/unit/sampletones_application/utils/file_dialogs/backends/test_command.py @@ -0,0 +1,40 @@ +from pathlib import Path +from unittest.mock import MagicMock, patch + +from sampletones_application.utils.file_dialogs.backends.command import run_dialog_command + +MODULE = "sampletones_application.utils.file_dialogs.backends.command" + +COMMAND = ["kdialog", "--getopenfilename"] + + +def _completed(stdout: str) -> MagicMock: + result = MagicMock() + result.stdout = stdout + return result + + +class TestRunDialogCommand: + def test_the_reported_path_reaches_the_caller(self) -> None: + with patch(f"{MODULE}.subprocess.run", return_value=_completed("/home/user/song.stp\n")) as run: + result = run_dialog_command(COMMAND) + + assert result == Path("/home/user/song.stp") + assert run.call_args.args[0] == COMMAND + + def test_the_tool_answers_on_standard_output(self) -> None: + """The path is read from the captured output, and a dismissal is read from it as well, + so the exit status stays with the caller of the tool. + """ + with patch(f"{MODULE}.subprocess.run", return_value=_completed("/audio/clip.wav")) as run: + run_dialog_command(COMMAND) + + assert run.call_args.kwargs == {"capture_output": True, "text": True, "check": False} + + def test_surrounding_whitespace_leaves_the_path(self) -> None: + with patch(f"{MODULE}.subprocess.run", return_value=_completed(" /audio/clip.wav \n")): + assert run_dialog_command(COMMAND) == Path("/audio/clip.wav") + + def test_empty_output_answers_none(self) -> None: + with patch(f"{MODULE}.subprocess.run", return_value=_completed("\n")): + assert run_dialog_command(COMMAND) is None diff --git a/tests/unit/sampletones_application/utils/file_dialogs/test_kdialog.py b/tests/unit/sampletones_application/utils/file_dialogs/backends/test_kdialog.py similarity index 52% rename from tests/unit/sampletones_application/utils/file_dialogs/test_kdialog.py rename to tests/unit/sampletones_application/utils/file_dialogs/backends/test_kdialog.py index 02c1be85..5e790418 100644 --- a/tests/unit/sampletones_application/utils/file_dialogs/test_kdialog.py +++ b/tests/unit/sampletones_application/utils/file_dialogs/backends/test_kdialog.py @@ -1,32 +1,34 @@ +from contextlib import AbstractContextManager from pathlib import Path +from typing import Optional from unittest.mock import MagicMock, patch +from sampletones_application.utils.file_dialogs.backends.kdialog import KDialogBackend +from sampletones_application.utils.file_dialogs.destination import SaveDestination from sampletones_application.utils.file_dialogs.filter import FileFilter -from sampletones_application.utils.file_dialogs.kdialog import KDialogBackend -MODULE = "sampletones_application.utils.file_dialogs.kdialog" +MODULE = "sampletones_application.utils.file_dialogs.backends.kdialog" -def _completed(stdout: str) -> MagicMock: - result = MagicMock() - result.stdout = stdout - return result +def _chosen(path: Optional[Path]) -> AbstractContextManager[MagicMock]: + """Answers the dialog with ``path``, standing in for what ``kdialog`` reports.""" + return patch(f"{MODULE}.run_dialog_command", return_value=path) class TestKDialogBackend: def test_save_command_carries_suggested_name_and_named_filter(self) -> None: backend = KDialogBackend() file_filter = FileFilter(name="Project files", patterns=("*.stp",)) - with patch(f"{MODULE}.subprocess.run", return_value=_completed("/home/user/song.stp\n")) as run: + with _chosen(Path("/home/user/song.stp")) as run: result = backend.save_file( title="Save project", initial_directory=Path("/home/user"), suggested_name="song.stp", - file_filter=file_filter, + filters=(file_filter,), ) command = run.call_args.args[0] - assert result == Path("/home/user/song.stp") + assert result == SaveDestination(path=Path("/home/user/song.stp"), file_type=None) assert "--getsavefilename" in command assert str(Path("/home/user/song.stp")) in command assert "*.stp|Project files (*.stp)" in command @@ -35,11 +37,11 @@ def test_save_command_carries_suggested_name_and_named_filter(self) -> None: def test_open_command_carries_multi_pattern_filter(self) -> None: backend = KDialogBackend() file_filter = FileFilter(name="Audio files", patterns=("*.wav", "*.mp3")) - with patch(f"{MODULE}.subprocess.run", return_value=_completed("/audio/clip.wav\n")) as run: + with _chosen(Path("/audio/clip.wav")) as run: result = backend.open_file( title="Open", initial_directory=Path("/audio"), - file_filter=file_filter, + filters=(file_filter,), ) command = run.call_args.args[0] @@ -47,9 +49,29 @@ def test_open_command_carries_multi_pattern_filter(self) -> None: assert "--getopenfilename" in command assert "*.wav *.mp3|Audio files (*.wav *.mp3)" in command + def test_several_types_gather_into_one_filter_naming_each(self) -> None: + """One filter reaches ``kdialog``'s command line, so it carries every accepted + pattern behind a label naming each type. + """ + backend = KDialogBackend() + filters = ( + FileFilter(name="FamiTracker instrument", patterns=("*.fti",)), + FileFilter(name="Bitphase preset", patterns=("*.json",)), + ) + with _chosen(Path("/home/user/kick.json")) as run: + backend.save_file( + title="Export instrument", + initial_directory=Path("/home/user"), + suggested_name="kick", + filters=filters, + ) + + command = run.call_args.args[0] + assert "*.fti *.json|FamiTracker instrument, Bitphase preset (*.fti *.json)" in command + def test_directory_command_has_no_filter(self) -> None: backend = KDialogBackend() - with patch(f"{MODULE}.subprocess.run", return_value=_completed("/audio/library\n")) as run: + with _chosen(Path("/audio/library")) as run: result = backend.select_directory(title="Choose", initial_directory=Path("/audio")) command = run.call_args.args[0] @@ -59,12 +81,12 @@ def test_directory_command_has_no_filter(self) -> None: def test_cancel_returns_none(self) -> None: backend = KDialogBackend() - with patch(f"{MODULE}.subprocess.run", return_value=_completed("")): + with _chosen(None): result = backend.save_file( title="Save", initial_directory=None, suggested_name=None, - file_filter=FileFilter(name="", patterns=("*.stp",)), + filters=(FileFilter(name="", patterns=("*.stp",)),), ) assert result is None diff --git a/tests/unit/sampletones_application/utils/file_dialogs/test_tkinter_backend.py b/tests/unit/sampletones_application/utils/file_dialogs/backends/test_tkinter.py similarity index 60% rename from tests/unit/sampletones_application/utils/file_dialogs/test_tkinter_backend.py rename to tests/unit/sampletones_application/utils/file_dialogs/backends/test_tkinter.py index 5f0c85fa..8fd284fc 100644 --- a/tests/unit/sampletones_application/utils/file_dialogs/test_tkinter_backend.py +++ b/tests/unit/sampletones_application/utils/file_dialogs/backends/test_tkinter.py @@ -1,10 +1,11 @@ from pathlib import Path from unittest.mock import patch +from sampletones_application.utils.file_dialogs.backends.tkinter import TkinterBackend +from sampletones_application.utils.file_dialogs.destination import SaveDestination from sampletones_application.utils.file_dialogs.filter import FileFilter -from sampletones_application.utils.file_dialogs.tkinter_backend import TkinterBackend -MODULE = "sampletones_application.utils.file_dialogs.tkinter_backend" +MODULE = "sampletones_application.utils.file_dialogs.backends.tkinter" class TestTkinterBackend: @@ -17,22 +18,43 @@ def test_save_passes_filetypes_and_disposes_root(self) -> None: title="Save", initial_directory=Path("/home/user"), suggested_name="song", - file_filter=file_filter, + filters=(file_filter,), ) kwargs = filedialog.asksaveasfilename.call_args.kwargs - assert result == Path("/home/user/song.stp") + assert result == SaveDestination(path=Path("/home/user/song.stp"), file_type=None) assert kwargs["filetypes"] == [("Project files (*.stp)", ("*.stp",))] assert kwargs["initialfile"] == "song" assert kwargs["initialdir"] == str(Path("/home/user")) tk.return_value.withdraw.assert_called_once() tk.return_value.destroy.assert_called_once() + def test_each_offered_type_becomes_its_own_filetype(self) -> None: + backend = TkinterBackend() + filters = ( + FileFilter(name="FamiTracker instrument", patterns=("*.fti",)), + FileFilter(name="Bitphase preset", patterns=("*.json",)), + ) + with patch(f"{MODULE}.Tk"), patch(f"{MODULE}.filedialog") as filedialog: + filedialog.asksaveasfilename.return_value = "/home/user/kick.json" + backend.save_file( + title="Export instrument", + initial_directory=None, + suggested_name="kick", + filters=filters, + ) + + kwargs = filedialog.asksaveasfilename.call_args.kwargs + assert kwargs["filetypes"] == [ + ("FamiTracker instrument (*.fti)", ("*.fti",)), + ("Bitphase preset (*.json)", ("*.json",)), + ] + def test_open_without_filter_uses_empty_filetypes(self) -> None: backend = TkinterBackend() with patch(f"{MODULE}.Tk"), patch(f"{MODULE}.filedialog") as filedialog: filedialog.askopenfilename.return_value = "" - result = backend.open_file(title="Open", initial_directory=None, file_filter=None) + result = backend.open_file(title="Open", initial_directory=None, filters=()) kwargs = filedialog.askopenfilename.call_args.kwargs assert result is None diff --git a/tests/unit/sampletones_application/utils/file_dialogs/backends/test_zenity.py b/tests/unit/sampletones_application/utils/file_dialogs/backends/test_zenity.py new file mode 100644 index 00000000..aa182eb3 --- /dev/null +++ b/tests/unit/sampletones_application/utils/file_dialogs/backends/test_zenity.py @@ -0,0 +1,87 @@ +import os +from contextlib import AbstractContextManager +from pathlib import Path +from typing import Optional +from unittest.mock import MagicMock, patch + +from sampletones_application.utils.file_dialogs.backends.zenity import ZenityBackend +from sampletones_application.utils.file_dialogs.destination import SaveDestination +from sampletones_application.utils.file_dialogs.filter import FileFilter + +MODULE = "sampletones_application.utils.file_dialogs.backends.zenity" + + +def _chosen(path: Optional[Path]) -> AbstractContextManager[MagicMock]: + """Answers the dialog with ``path``, standing in for what ``zenity`` reports.""" + return patch(f"{MODULE}.run_dialog_command", return_value=path) + + +class TestZenityBackend: + def test_save_command_uses_named_filter_and_filename(self) -> None: + backend = ZenityBackend() + file_filter = FileFilter(name="Project files", patterns=("*.stp",)) + with _chosen(Path("/home/user/song.stp")) as run: + result = backend.save_file( + title="Save project", + initial_directory=Path("/home/user"), + suggested_name="song.stp", + filters=(file_filter,), + ) + + command = run.call_args.args[0] + assert result == SaveDestination(path=Path("/home/user/song.stp"), file_type=None) + assert "--save" in command + assert command[command.index("--file-filter") + 1] == "Project files (*.stp) | *.stp" + assert command[command.index("--filename") + 1] == str(Path("/home/user/song.stp")) + + def test_open_command_filter_format(self) -> None: + backend = ZenityBackend() + file_filter = FileFilter(name="Audio files", patterns=("*.wav", "*.mp3")) + with _chosen(Path("/audio/clip.wav")) as run: + backend.open_file(title="Open", initial_directory=Path("/audio"), filters=(file_filter,)) + + command = run.call_args.args[0] + assert command[command.index("--file-filter") + 1] == "Audio files (*.wav *.mp3) | *.wav *.mp3" + + def test_each_offered_type_reaches_the_selector_as_its_own_entry(self) -> None: + """GTK narrows the browser by the type picked in the selector, so every accepted + type is listed for itself. + """ + backend = ZenityBackend() + filters = ( + FileFilter(name="FamiTracker instrument", patterns=("*.fti",)), + FileFilter(name="Bitphase preset", patterns=("*.json",)), + ) + with _chosen(Path("/home/user/kick.json")) as run: + backend.save_file( + title="Export instrument", + initial_directory=Path("/home/user"), + suggested_name="kick", + filters=filters, + ) + + command = run.call_args.args[0] + assert command.count("--file-filter") == 2 + assert "FamiTracker instrument (*.fti) | *.fti" in command + assert "Bitphase preset (*.json) | *.json" in command + + def test_directory_command_uses_directory_flag(self) -> None: + backend = ZenityBackend() + with _chosen(Path("/audio/library")) as run: + result = backend.select_directory(title="Choose", initial_directory=Path("/audio")) + + command = run.call_args.args[0] + assert result == Path("/audio/library") + assert "--directory" in command + assert command[command.index("--filename") + 1].endswith(os.sep) + + def test_cancel_returns_none(self) -> None: + backend = ZenityBackend() + with _chosen(None): + result = backend.open_file( + title="Open", + initial_directory=None, + filters=(FileFilter(name="", patterns=("*.stp",)),), + ) + + assert result is None diff --git a/tests/unit/sampletones_application/utils/file_dialogs/test_api.py b/tests/unit/sampletones_application/utils/file_dialogs/test_api.py index a9196f97..cf58b61f 100644 --- a/tests/unit/sampletones_application/utils/file_dialogs/test_api.py +++ b/tests/unit/sampletones_application/utils/file_dialogs/test_api.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import List, Optional, Tuple +from typing import Final, List, Optional, Tuple from unittest.mock import patch from sampletones_application.utils.file_dialogs.api import ( @@ -7,22 +7,39 @@ save_file_dialog, select_directory_dialog, ) +from sampletones_application.utils.file_dialogs.destination import SaveDestination from sampletones_application.utils.file_dialogs.filter import FileFilter MODULE = "sampletones_application.utils.file_dialogs.api" -Call = Tuple[str, str, Optional[Path], Optional[FileFilter]] +PROJECT_FILTER: Final[FileFilter] = FileFilter(name="Project files", patterns=("*.stp",)) +FAMITRACKER_FILTER: Final[FileFilter] = FileFilter(name="FamiTracker instrument", patterns=("*.fti",)) +PRESET_FILTER: Final[FileFilter] = FileFilter(name="Bitphase preset", patterns=("*.json",)) +INSTRUMENT_FILTERS: Final[Tuple[FileFilter, ...]] = ( + FAMITRACKER_FILTER, + FileFilter(name="Bitphase project", patterns=("*.btp",)), + PRESET_FILTER, +) + +Call = Tuple[str, str, Optional[Path], Tuple[FileFilter, ...]] class FakeBackend: - def __init__(self, result: Optional[Path]) -> None: + """A backend answering with one prepared path, and the type it reports having been chosen.""" + + def __init__( + self, + result: Optional[Path], + reported_type: Optional[FileFilter] = None, + ) -> None: self._result = result + self._reported_type = reported_type self.calls: List[Call] = [] def open_file( - self, *, title: str, initial_directory: Optional[Path], file_filter: Optional[FileFilter] + self, *, title: str, initial_directory: Optional[Path], filters: Tuple[FileFilter, ...] ) -> Optional[Path]: - self.calls.append(("open", title, initial_directory, file_filter)) + self.calls.append(("open", title, initial_directory, filters)) return self._result def save_file( @@ -31,13 +48,16 @@ def save_file( title: str, initial_directory: Optional[Path], suggested_name: Optional[str], - file_filter: Optional[FileFilter], - ) -> Optional[Path]: - self.calls.append(("save", title, initial_directory, file_filter)) - return self._result + filters: Tuple[FileFilter, ...], + ) -> Optional[SaveDestination]: + self.calls.append(("save", title, initial_directory, filters)) + if self._result is None: + return None + + return SaveDestination(path=self._result, file_type=self._reported_type) def select_directory(self, *, title: str, initial_directory: Optional[Path]) -> Optional[Path]: - self.calls.append(("directory", title, initial_directory, None)) + self.calls.append(("directory", title, initial_directory, ())) return self._result @@ -45,33 +65,85 @@ class TestSaveFileDialog: def test_appends_missing_extension(self) -> None: backend = FakeBackend(Path("/home/user/song")) with patch(f"{MODULE}.select_file_dialog_backend", return_value=backend): - result = save_file_dialog(title="Save", extensions=[".stp"], filter_name="Project files") + result = save_file_dialog(title="Save", filters=(PROJECT_FILTER,)) assert result == Path("/home/user/song.stp") - file_filter = backend.calls[0][3] - assert file_filter == FileFilter(name="Project files", patterns=("*.stp",)) + assert backend.calls[0][3] == (PROJECT_FILTER,) def test_keeps_present_extension(self) -> None: backend = FakeBackend(Path("/home/user/song.stp")) with patch(f"{MODULE}.select_file_dialog_backend", return_value=backend): - result = save_file_dialog(title="Save", extensions=[".stp"]) + result = save_file_dialog(title="Save", filters=(PROJECT_FILTER,)) assert result == Path("/home/user/song.stp") def test_cancel_returns_none(self) -> None: backend = FakeBackend(None) with patch(f"{MODULE}.select_file_dialog_backend", return_value=backend): - result = save_file_dialog(title="Save", extensions=[".stp"]) + result = save_file_dialog(title="Save", filters=(PROJECT_FILTER,)) assert result is None + def test_a_bare_name_takes_the_first_of_several_offered_types(self) -> None: + backend = FakeBackend(Path("/home/user/kick")) + with patch(f"{MODULE}.select_file_dialog_backend", return_value=backend): + result = save_file_dialog(title="Save", filters=INSTRUMENT_FILTERS) + + assert result == Path("/home/user/kick.fti") + + def test_a_typed_extension_chooses_among_several_offered_types(self) -> None: + backend = FakeBackend(Path("/home/user/kick.json")) + with patch(f"{MODULE}.select_file_dialog_backend", return_value=backend): + result = save_file_dialog(title="Save", filters=INSTRUMENT_FILTERS) + + assert result == Path("/home/user/kick.json") + + def test_the_reported_type_names_a_bare_name(self) -> None: + backend = FakeBackend(Path("/home/user/kick"), reported_type=PRESET_FILTER) + with patch(f"{MODULE}.select_file_dialog_backend", return_value=backend): + result = save_file_dialog(title="Save", filters=INSTRUMENT_FILTERS) + + assert result == Path("/home/user/kick.json") + + def test_a_typed_extension_stands_over_the_reported_type(self) -> None: + """Typing an offered extension names the type, whichever one the selector stood on.""" + backend = FakeBackend(Path("/home/user/kick.fti"), reported_type=PRESET_FILTER) + with patch(f"{MODULE}.select_file_dialog_backend", return_value=backend): + result = save_file_dialog(title="Save", filters=INSTRUMENT_FILTERS) + + assert result == Path("/home/user/kick.fti") + + def test_an_extension_outside_the_offered_types_takes_the_reported_one(self) -> None: + backend = FakeBackend(Path("/home/user/kick.xm"), reported_type=PRESET_FILTER) + with patch(f"{MODULE}.select_file_dialog_backend", return_value=backend): + result = save_file_dialog(title="Save", filters=INSTRUMENT_FILTERS) + + assert result == Path("/home/user/kick.xm.json") + + def test_a_dotted_name_is_saved_as_the_governing_type(self) -> None: + """A name carrying dots of its own keeps them, so ``Kick 1.2`` saves as a file of the + type the dialog stood on rather than one named after its trailing segment. + """ + backend = FakeBackend(Path("/home/user/Kick 1.2")) + with patch(f"{MODULE}.select_file_dialog_backend", return_value=backend): + result = save_file_dialog(title="Save", filters=INSTRUMENT_FILTERS) + + assert result == Path("/home/user/Kick 1.2.fti") + + def test_one_offered_type_is_saved_as_that_type(self) -> None: + backend = FakeBackend(Path("/home/user/Kick 1.2")) + with patch(f"{MODULE}.select_file_dialog_backend", return_value=backend): + result = save_file_dialog(title="Save", filters=(PROJECT_FILTER,)) + + assert result == Path("/home/user/Kick 1.2.stp") + def test_without_extension_no_filter_and_no_append(self) -> None: backend = FakeBackend(Path("/home/user/song")) with patch(f"{MODULE}.select_file_dialog_backend", return_value=backend): result = save_file_dialog(title="Save") assert result == Path("/home/user/song") - assert backend.calls[0][3] is None + assert backend.calls[0][3] == () class TestOpenFileDialog: @@ -81,14 +153,13 @@ def test_builds_filter_and_converts_directory(self) -> None: result = open_file_dialog( title="Open", initial_directory="/audio", - extensions=[".wav", ".mp3"], - filter_name="Audio files", + filters=(FileFilter.for_extensions("Audio files", [".wav", ".mp3"]),), ) assert result == Path("/audio/clip.wav") - _, _, initial_directory, file_filter = backend.calls[0] + _, _, initial_directory, filters = backend.calls[0] assert initial_directory == Path("/audio") - assert file_filter == FileFilter(name="Audio files", patterns=("*.wav", "*.mp3")) + assert filters == (FileFilter(name="Audio files", patterns=("*.wav", "*.mp3")),) class TestSelectDirectoryDialog: @@ -98,4 +169,4 @@ def test_passes_through(self) -> None: result = select_directory_dialog(title="Choose", initial_directory="/audio") assert result == Path("/audio/library") - assert backend.calls[0] == ("directory", "Choose", Path("/audio"), None) + assert backend.calls[0] == ("directory", "Choose", Path("/audio"), ()) diff --git a/tests/unit/sampletones_application/utils/file_dialogs/test_filter.py b/tests/unit/sampletones_application/utils/file_dialogs/test_filter.py index b58ce28b..c93d0f92 100644 --- a/tests/unit/sampletones_application/utils/file_dialogs/test_filter.py +++ b/tests/unit/sampletones_application/utils/file_dialogs/test_filter.py @@ -1,8 +1,15 @@ -from typing import Tuple +from typing import Optional, Tuple import pytest -from sampletones_application.utils.file_dialogs.filter import FileFilter, normalize_extensions +from sampletones_application.utils.file_dialogs.filter import ( + FileFilter, + merge_filters, + normalize_extensions, +) + +FAMITRACKER_INSTRUMENT = FileFilter(name="FamiTracker instrument", patterns=("*.fti",)) +BITPHASE_PRESET = FileFilter(name="Bitphase preset", patterns=("*.json",)) @pytest.mark.parametrize( @@ -29,3 +36,50 @@ def test_normalize_extensions(extensions: Tuple[str, ...], expected: Tuple[str, ) def test_label(name: str, patterns: Tuple[str, ...], expected: str) -> None: assert FileFilter(name=name, patterns=patterns).label == expected + + +@pytest.mark.parametrize( + "extensions, expected", + [ + ([".fti"], (".fti",)), + (["*.fti"], (".fti",)), + ([".fti", ".btp", ".json"], (".fti", ".btp", ".json")), + ], +) +def test_a_type_names_the_extensions_it_matches( + extensions: Tuple[str, ...], + expected: Tuple[str, ...], +) -> None: + """The glob form belongs to the dialogs, so a caller reads plain extensions back out.""" + assert FileFilter.for_extensions("Instrument", extensions).extensions == expected + + +@pytest.mark.parametrize( + "filters, expected", + [ + ((), None), + ((FAMITRACKER_INSTRUMENT,), FAMITRACKER_INSTRUMENT), + ( + (FAMITRACKER_INSTRUMENT, BITPHASE_PRESET), + FileFilter(name="FamiTracker instrument, Bitphase preset", patterns=("*.fti", "*.json")), + ), + ], +) +def test_merge_filters( + filters: Tuple[FileFilter, ...], + expected: Optional[FileFilter], +) -> None: + assert merge_filters(filters) == expected + + +def test_merging_leaves_one_type_alone() -> None: + """A lone type keeps its single pattern, which is the form a dialog fills the + extension in for. + """ + assert merge_filters((BITPHASE_PRESET,)).patterns == ("*.json",) + + +def test_merging_offers_a_shared_pattern_once() -> None: + audio = FileFilter(name="Audio", patterns=("*.wav", "*.mp3")) + wave = FileFilter(name="WAV audio", patterns=("*.wav",)) + assert merge_filters((audio, wave)).patterns == ("*.wav", "*.mp3") diff --git a/tests/unit/sampletones_application/utils/file_dialogs/test_selection.py b/tests/unit/sampletones_application/utils/file_dialogs/test_selection.py index 74e9c898..dcbf9424 100644 --- a/tests/unit/sampletones_application/utils/file_dialogs/test_selection.py +++ b/tests/unit/sampletones_application/utils/file_dialogs/test_selection.py @@ -1,17 +1,21 @@ import os +from contextlib import AbstractContextManager from typing import Callable, Optional -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest -from sampletones_application.utils.file_dialogs.kdialog import KDialogBackend +from sampletones_application.utils.file_dialogs.backends.kdialog import KDialogBackend +from sampletones_application.utils.file_dialogs.backends.portal.backend import PortalBackend +from sampletones_application.utils.file_dialogs.backends.portal.client import FileChooserClient +from sampletones_application.utils.file_dialogs.backends.tkinter import TkinterBackend +from sampletones_application.utils.file_dialogs.backends.zenity import ZenityBackend from sampletones_application.utils.file_dialogs.selection import select_file_dialog_backend -from sampletones_application.utils.file_dialogs.tkinter_backend import TkinterBackend -from sampletones_application.utils.file_dialogs.zenity import ZenityBackend from sampletones_shared.exceptions import FileDialogUnavailableError from sampletones_shared.utils.system.system import System MODULE = "sampletones_application.utils.file_dialogs.selection" +PORTAL_MODULE = "sampletones_application.utils.file_dialogs.backends.portal.backend" def _which(*, kdialog: bool, zenity: bool) -> Callable[[str], Optional[str]]: @@ -23,6 +27,11 @@ def resolver(tool: str) -> Optional[str]: return resolver +def _portal(backend: Optional[PortalBackend]) -> AbstractContextManager[MagicMock]: + """Answers the portal probe with ``backend``, standing in for a desktop that runs one.""" + return patch(f"{PORTAL_MODULE}.portal_backend", return_value=backend) + + def _find_spec(available: bool) -> Callable[[str], Optional[object]]: def resolver(module: str) -> Optional[object]: return object() if available else None @@ -39,10 +48,22 @@ def test_macos_uses_tkinter(self) -> None: with patch(f"{MODULE}.System.current", return_value=System.MACOS): assert isinstance(select_file_dialog_backend(), TkinterBackend) + def test_the_portal_leads_where_it_answers(self) -> None: + """The portal lists every offered type and reports the chosen one, so it comes first.""" + portal = PortalBackend(FileChooserClient()) + with ( + patch(f"{MODULE}.System.current", return_value=System.LINUX), + patch(f"{MODULE}.shutil.which", side_effect=_which(kdialog=True, zenity=True)), + _portal(portal), + patch.dict(os.environ, {"XDG_CURRENT_DESKTOP": "KDE"}, clear=False), + ): + assert select_file_dialog_backend() is portal + def test_kde_prefers_kdialog(self) -> None: with ( patch(f"{MODULE}.System.current", return_value=System.LINUX), patch(f"{MODULE}.shutil.which", side_effect=_which(kdialog=True, zenity=True)), + _portal(None), patch.dict(os.environ, {"XDG_CURRENT_DESKTOP": "KDE"}, clear=False), ): assert isinstance(select_file_dialog_backend(), KDialogBackend) @@ -51,6 +72,7 @@ def test_gnome_prefers_zenity(self) -> None: with ( patch(f"{MODULE}.System.current", return_value=System.LINUX), patch(f"{MODULE}.shutil.which", side_effect=_which(kdialog=True, zenity=True)), + _portal(None), patch.dict(os.environ, {"XDG_CURRENT_DESKTOP": "GNOME"}, clear=False), ): assert isinstance(select_file_dialog_backend(), ZenityBackend) @@ -59,6 +81,7 @@ def test_kde_without_kdialog_falls_back_to_zenity(self) -> None: with ( patch(f"{MODULE}.System.current", return_value=System.LINUX), patch(f"{MODULE}.shutil.which", side_effect=_which(kdialog=False, zenity=True)), + _portal(None), patch.dict(os.environ, {"XDG_CURRENT_DESKTOP": "KDE"}, clear=False), ): assert isinstance(select_file_dialog_backend(), ZenityBackend) @@ -67,6 +90,7 @@ def test_no_linux_tools_uses_tkinter(self) -> None: with ( patch(f"{MODULE}.System.current", return_value=System.LINUX), patch(f"{MODULE}.shutil.which", side_effect=_which(kdialog=False, zenity=False)), + _portal(None), patch.dict(os.environ, {"XDG_CURRENT_DESKTOP": "GNOME"}, clear=False), ): assert isinstance(select_file_dialog_backend(), TkinterBackend) diff --git a/tests/unit/sampletones_application/utils/file_dialogs/test_zenity.py b/tests/unit/sampletones_application/utils/file_dialogs/test_zenity.py deleted file mode 100644 index cdbc24e7..00000000 --- a/tests/unit/sampletones_application/utils/file_dialogs/test_zenity.py +++ /dev/null @@ -1,63 +0,0 @@ -import os -from pathlib import Path -from unittest.mock import MagicMock, patch - -from sampletones_application.utils.file_dialogs.filter import FileFilter -from sampletones_application.utils.file_dialogs.zenity import ZenityBackend - -MODULE = "sampletones_application.utils.file_dialogs.zenity" - - -def _completed(stdout: str) -> MagicMock: - result = MagicMock() - result.stdout = stdout - return result - - -class TestZenityBackend: - def test_save_command_uses_named_filter_and_filename(self) -> None: - backend = ZenityBackend() - file_filter = FileFilter(name="Project files", patterns=("*.stp",)) - with patch(f"{MODULE}.subprocess.run", return_value=_completed("/home/user/song.stp\n")) as run: - result = backend.save_file( - title="Save project", - initial_directory=Path("/home/user"), - suggested_name="song.stp", - file_filter=file_filter, - ) - - command = run.call_args.args[0] - assert result == Path("/home/user/song.stp") - assert "--save" in command - assert command[command.index("--file-filter") + 1] == "Project files (*.stp) | *.stp" - assert command[command.index("--filename") + 1] == str(Path("/home/user/song.stp")) - - def test_open_command_filter_format(self) -> None: - backend = ZenityBackend() - file_filter = FileFilter(name="Audio files", patterns=("*.wav", "*.mp3")) - with patch(f"{MODULE}.subprocess.run", return_value=_completed("/audio/clip.wav\n")) as run: - backend.open_file(title="Open", initial_directory=Path("/audio"), file_filter=file_filter) - - command = run.call_args.args[0] - assert command[command.index("--file-filter") + 1] == "Audio files (*.wav *.mp3) | *.wav *.mp3" - - def test_directory_command_uses_directory_flag(self) -> None: - backend = ZenityBackend() - with patch(f"{MODULE}.subprocess.run", return_value=_completed("/audio/library\n")) as run: - result = backend.select_directory(title="Choose", initial_directory=Path("/audio")) - - command = run.call_args.args[0] - assert result == Path("/audio/library") - assert "--directory" in command - assert command[command.index("--filename") + 1].endswith(os.sep) - - def test_cancel_returns_none(self) -> None: - backend = ZenityBackend() - with patch(f"{MODULE}.subprocess.run", return_value=_completed("")): - result = backend.open_file( - title="Open", - initial_directory=None, - file_filter=FileFilter(name="", patterns=("*.stp",)), - ) - - assert result is None diff --git a/tests/unit/sampletones_core/exporters/implementation/test_noise.py b/tests/unit/sampletones_core/exporters/implementation/test_noise.py index 232895af..a2852ea5 100644 --- a/tests/unit/sampletones_core/exporters/implementation/test_noise.py +++ b/tests/unit/sampletones_core/exporters/implementation/test_noise.py @@ -68,45 +68,47 @@ def test_empty_instruction_list(self) -> None: assert duty_cycles == [] +class TestNoiseExporterDeriveInitialPitch: + def test_reference_is_the_first_sounding_period(self) -> None: + instructions = [_off(), _noise(period=7, volume=10), _noise(period=2, volume=10)] + assert NoiseExporter.derive_initial_pitch(instructions) == 7 + + def test_empty_instruction_list_references_period_zero(self) -> None: + assert NoiseExporter.derive_initial_pitch([]) == 0 + + class TestNoiseExporterGetFeatureMap: def test_feature_map_contains_all_required_keys(self) -> None: - feature_map = NoiseExporter.get_feature_map( - [ - _noise( - period=3, - volume=10, - ) - ] - ) + feature_map = NoiseExporter.get_feature_map([_noise(period=3, volume=10)], 3) assert FeatureKey.INITIAL_PITCH in feature_map assert FeatureKey.VOLUME in feature_map assert FeatureKey.ARPEGGIO in feature_map assert FeatureKey.DUTY_CYCLE in feature_map - def test_arpeggio_is_relative_to_initial_period_modulo_num_periods(self) -> None: + def test_arpeggio_is_relative_to_the_given_reference_modulo_num_periods(self) -> None: instructions = [ _noise(period=2, volume=10), - _noise( - period=5, - volume=8, - ), + _noise(period=5, volume=8), ] - feature_map = NoiseExporter.get_feature_map(instructions) - initial = feature_map[FeatureKey.INITIAL_PITCH] + feature_map = NoiseExporter.get_feature_map(instructions, 4) arpeggio = feature_map[FeatureKey.ARPEGGIO] - assert int(arpeggio[0]) == (2 - initial) % NUM_PERIODS - assert int(arpeggio[1]) == (5 - initial) % NUM_PERIODS + assert int(arpeggio[0]) == (2 - 4) % NUM_PERIODS + assert int(arpeggio[1]) == (5 - 4) % NUM_PERIODS + + def test_initial_pitch_is_the_given_reference(self) -> None: + feature_map = NoiseExporter.get_feature_map([_noise(period=2, volume=10)], 9) + assert feature_map[FeatureKey.INITIAL_PITCH] == 9 def test_volume_array_dtype_is_int8(self) -> None: - feature_map = NoiseExporter.get_feature_map([_noise()]) + feature_map = NoiseExporter.get_feature_map([_noise()], 0) assert feature_map[FeatureKey.VOLUME].dtype == np.int8 def test_arpeggio_dtype_is_int8(self) -> None: - feature_map = NoiseExporter.get_feature_map([_noise()]) + feature_map = NoiseExporter.get_feature_map([_noise()], 0) assert feature_map[FeatureKey.ARPEGGIO].dtype == np.int8 def test_duty_cycle_dtype_is_int8(self) -> None: - feature_map = NoiseExporter.get_feature_map([_noise()]) + feature_map = NoiseExporter.get_feature_map([_noise()], 0) assert feature_map[FeatureKey.DUTY_CYCLE].dtype == np.int8 diff --git a/tests/unit/sampletones_core/exporters/implementation/test_pulse.py b/tests/unit/sampletones_core/exporters/implementation/test_pulse.py new file mode 100644 index 00000000..0b4e842e --- /dev/null +++ b/tests/unit/sampletones_core/exporters/implementation/test_pulse.py @@ -0,0 +1,134 @@ +import numpy as np + +from sampletones_core.constants.enums import FeatureKey +from sampletones_core.constants.general import MAX_PITCH, MIN_PITCH +from sampletones_core.exporters.implementation.pulse import PulseExporter +from sampletones_core.generators import PulseGenerator +from sampletones_core.instructions.implementation.pulse import PulseInstruction + + +def _pulse(pitch: int = 60, volume: int = 8, duty_cycle: int = 0) -> PulseInstruction: + return PulseInstruction(on=volume > 0, pitch=pitch, volume=volume, duty_cycle=duty_cycle) + + +def _off() -> PulseInstruction: + return PulseInstruction(on=False, pitch=MIN_PITCH, volume=0, duty_cycle=0) + + +class TestPulseExporterExtractData: + def test_initial_pitch_from_first_on_instruction(self) -> None: + initial_pitch, _, _, _ = PulseExporter.extract_data([_pulse(pitch=70)]) + assert initial_pitch == 70 + + def test_all_off_instructions_initial_pitch_is_min_pitch(self) -> None: + initial_pitch, _, _, _ = PulseExporter.extract_data([_off(), _off()]) + assert initial_pitch == MIN_PITCH + + def test_off_instruction_produces_zero_volume(self) -> None: + _, _, volumes, _ = PulseExporter.extract_data([_pulse(pitch=60), _off()]) + assert volumes[1] == 0 + + def test_on_instruction_carries_its_volume(self) -> None: + _, _, volumes, _ = PulseExporter.extract_data([_pulse(pitch=60, volume=12)]) + assert volumes[0] == 12 + + def test_trailing_nonzero_volume_appends_extra_zero(self) -> None: + _, _, volumes, _ = PulseExporter.extract_data([_pulse(pitch=60)]) + assert volumes[-1] == 0 + assert len(volumes) == 2 + + def test_off_instructions_before_on_get_backfilled(self) -> None: + _, pitches, _, _ = PulseExporter.extract_data([_off(), _pulse(pitch=55)]) + assert pitches[0] == 55 + + def test_duty_cycle_tracks_the_instruction(self) -> None: + _, _, _, duty_cycles = PulseExporter.extract_data([_pulse(pitch=60, duty_cycle=2)]) + assert duty_cycles[0] == 2 + + def test_empty_instruction_list_returns_min_pitch(self) -> None: + initial_pitch, pitches, volumes, duty_cycles = PulseExporter.extract_data([]) + assert initial_pitch == MIN_PITCH + assert pitches == [] + assert volumes == [] + assert duty_cycles == [] + + +class TestPulseExporterDeriveInitialPitch: + def test_reference_is_the_midpoint_of_the_contour(self) -> None: + instructions = [_pulse(pitch=60), _pulse(pitch=72)] + assert PulseExporter.derive_initial_pitch(instructions) == 66 + + def test_flat_contour_references_its_own_pitch(self) -> None: + instructions = [_pulse(pitch=60), _pulse(pitch=60)] + assert PulseExporter.derive_initial_pitch(instructions) == 60 + + def test_empty_instruction_list_references_min_pitch(self) -> None: + assert PulseExporter.derive_initial_pitch([]) == MIN_PITCH + + +class TestPulseExporterGetFeatureMap: + def test_feature_map_contains_required_keys(self) -> None: + feature_map = PulseExporter.get_feature_map([_pulse(pitch=60)], 60) + assert FeatureKey.INITIAL_PITCH in feature_map + assert FeatureKey.VOLUME in feature_map + assert FeatureKey.ARPEGGIO in feature_map + assert FeatureKey.DUTY_CYCLE in feature_map + + def test_arpeggio_is_relative_to_the_given_reference(self) -> None: + instructions = [_pulse(pitch=60), _pulse(pitch=65)] + feature_map = PulseExporter.get_feature_map(instructions, 60) + arpeggio = feature_map[FeatureKey.ARPEGGIO] + assert int(arpeggio[0]) == 0 + assert int(arpeggio[1]) == 5 + + def test_initial_pitch_is_the_given_reference(self) -> None: + feature_map = PulseExporter.get_feature_map([_pulse(pitch=60)], 55) + assert feature_map[FeatureKey.INITIAL_PITCH] == 55 + assert int(feature_map[FeatureKey.ARPEGGIO][0]) == 5 + + def test_volume_dtype_is_int8(self) -> None: + feature_map = PulseExporter.get_feature_map([_pulse()], 60) + assert feature_map[FeatureKey.VOLUME].dtype == np.int8 + + def test_arpeggio_dtype_is_int8(self) -> None: + feature_map = PulseExporter.get_feature_map([_pulse()], 60) + assert feature_map[FeatureKey.ARPEGGIO].dtype == np.int8 + + def test_duty_cycle_dtype_is_int8(self) -> None: + feature_map = PulseExporter.get_feature_map([_pulse()], 60) + assert feature_map[FeatureKey.DUTY_CYCLE].dtype == np.int8 + + +class TestPulseExporterReconstruction: + def test_valid_pitch_round_trips(self) -> None: + initial_pitch = 50 + arpeggio = 10 + dictionary = {"pitch": arpeggio, "volume": 8, "duty_cycle": 1} + result = PulseExporter._features_dictionary_to_instruction(dictionary, initial_pitch) + assert result.pitch == initial_pitch + arpeggio + assert result.volume == 8 + assert result.duty_cycle == 1 + assert result.on is True + + def test_invalid_pitch_above_max_returns_null_instruction(self) -> None: + dictionary = {"pitch": 10, "volume": 8, "duty_cycle": 0} + result = PulseExporter._features_dictionary_to_instruction(dictionary, MAX_PITCH) + assert result.on is False + + def test_invalid_pitch_below_min_returns_null_instruction(self) -> None: + dictionary = {"pitch": -10, "volume": 8, "duty_cycle": 0} + result = PulseExporter._features_dictionary_to_instruction(dictionary, MIN_PITCH) + assert result.on is False + + def test_zero_volume_reconstructed_as_off(self) -> None: + dictionary = {"pitch": 0, "volume": 0, "duty_cycle": 0} + result = PulseExporter._features_dictionary_to_instruction(dictionary, 60) + assert result.on is False + + +class TestPulseExporterTypeGetters: + def test_get_instruction_type_returns_pulse_instruction(self) -> None: + assert PulseExporter.get_instruction_type() is PulseInstruction + + def test_get_generator_type_returns_pulse_generator(self) -> None: + assert PulseExporter.get_generator_type() is PulseGenerator diff --git a/tests/unit/sampletones_core/exporters/implementation/test_triangle.py b/tests/unit/sampletones_core/exporters/implementation/test_triangle.py index 678033a7..517cd291 100644 --- a/tests/unit/sampletones_core/exporters/implementation/test_triangle.py +++ b/tests/unit/sampletones_core/exporters/implementation/test_triangle.py @@ -52,27 +52,44 @@ def test_empty_instruction_list_returns_min_pitch(self) -> None: assert volumes == [] +class TestTriangleExporterDeriveInitialPitch: + def test_reference_is_the_midpoint_of_the_contour(self) -> None: + instructions = [_tri(pitch=60), _tri(pitch=72)] + assert TriangleExporter.derive_initial_pitch(instructions) == 66 + + def test_flat_contour_references_its_own_pitch(self) -> None: + instructions = [_tri(pitch=60), _tri(pitch=60)] + assert TriangleExporter.derive_initial_pitch(instructions) == 60 + + def test_empty_instruction_list_references_min_pitch(self) -> None: + assert TriangleExporter.derive_initial_pitch([]) == MIN_PITCH + + class TestTriangleExporterGetFeatureMap: def test_feature_map_contains_required_keys(self) -> None: - feature_map = TriangleExporter.get_feature_map([_tri(pitch=60)]) + feature_map = TriangleExporter.get_feature_map([_tri(pitch=60)], 60) assert FeatureKey.INITIAL_PITCH in feature_map assert FeatureKey.VOLUME in feature_map assert FeatureKey.ARPEGGIO in feature_map - def test_arpeggio_is_relative_pitch_difference(self) -> None: + def test_arpeggio_is_relative_to_the_given_reference(self) -> None: instructions = [_tri(pitch=60), _tri(pitch=65)] - feature_map = TriangleExporter.get_feature_map(instructions) - initial = feature_map[FeatureKey.INITIAL_PITCH] + feature_map = TriangleExporter.get_feature_map(instructions, 60) arpeggio = feature_map[FeatureKey.ARPEGGIO] - assert int(arpeggio[0]) == 60 - initial - assert int(arpeggio[1]) == 65 - initial + assert int(arpeggio[0]) == 0 + assert int(arpeggio[1]) == 5 + + def test_initial_pitch_is_the_given_reference(self) -> None: + feature_map = TriangleExporter.get_feature_map([_tri(pitch=60)], 55) + assert feature_map[FeatureKey.INITIAL_PITCH] == 55 + assert int(feature_map[FeatureKey.ARPEGGIO][0]) == 5 def test_volume_dtype_is_int8(self) -> None: - feature_map = TriangleExporter.get_feature_map([_tri()]) + feature_map = TriangleExporter.get_feature_map([_tri()], 60) assert feature_map[FeatureKey.VOLUME].dtype == np.int8 def test_arpeggio_dtype_is_int8(self) -> None: - feature_map = TriangleExporter.get_feature_map([_tri()]) + feature_map = TriangleExporter.get_feature_map([_tri()], 60) assert feature_map[FeatureKey.ARPEGGIO].dtype == np.int8 diff --git a/tests/unit/sampletones_core/exporters/test_exporter.py b/tests/unit/sampletones_core/exporters/test_exporter.py new file mode 100644 index 00000000..9b818b23 --- /dev/null +++ b/tests/unit/sampletones_core/exporters/test_exporter.py @@ -0,0 +1,265 @@ +from dataclasses import dataclass +from typing import Any, Callable, Final, List, Sequence + +import numpy as np +import pytest + +from sampletones_core.constants.enums import FeatureKey +from sampletones_core.exporters import ( + ExporterTypeUnion, + Features, + NoiseExporter, + PulseExporter, + TriangleExporter, +) +from sampletones_core.instructions import ( + InstructionUnion, + NoiseInstruction, + PulseInstruction, + TriangleInstruction, +) +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + +REFERENCE_PITCH: Final[int] = 60 +REFERENCE_PERIOD: Final[int] = 4 +SOUNDING_FRAMES: Final[int] = 5 +OCTAVE: Final[int] = 12 +PERIOD_STEP: Final[int] = 3 +PULSE_VOLUME: Final[int] = 8 +NOISE_VOLUME: Final[int] = 10 + + +def _read_pitch(instruction: Any) -> int: + pitch: int = instruction.pitch + return pitch + + +def _read_period(instruction: Any) -> int: + period: int = instruction.period + return period + + +def _pulse_line(pitch: int) -> List[PulseInstruction]: + return [PulseInstruction(on=True, pitch=pitch, volume=PULSE_VOLUME, duty_cycle=0) for _ in range(SOUNDING_FRAMES)] + + +def _triangle_line(pitch: int) -> List[TriangleInstruction]: + return [TriangleInstruction(on=True, pitch=pitch) for _ in range(SOUNDING_FRAMES)] + + +def _noise_line(period: int) -> List[NoiseInstruction]: + return [NoiseInstruction(on=True, period=period, volume=NOISE_VOLUME, short=False) for _ in range(SOUNDING_FRAMES)] + + +class TestArpeggioReferenceStability(BaseTestSuite): + """The reference pitch an arpeggio is measured against holds across an edit to the envelope. + + Each case walks the sequence a user performs in the instruments panel: a flat contour is + anchored once, an arpeggio envelope is typed in, the channel is rebuilt and exported again + against the stored anchor, and the envelope is finally cleared. The last step is the guard — + clearing the envelope returns every frame to the reference it started from, including the + frames the envelope is too short to cover. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + expected: int + exporter: ExporterTypeUnion + instructions: Sequence[InstructionUnion] + read_pitch: Callable[[Any], int] + arpeggio: np.ndarray + edited_pitches: List[int] + + test_cases = [ + TestCase( + label="pulse", + exporter=PulseExporter, + instructions=_pulse_line(REFERENCE_PITCH), + read_pitch=_read_pitch, + arpeggio=np.array([OCTAVE, 0], dtype=np.int8), + edited_pitches=[REFERENCE_PITCH + OCTAVE] + [REFERENCE_PITCH] * SOUNDING_FRAMES, + expected=REFERENCE_PITCH, + ), + TestCase( + label="triangle", + exporter=TriangleExporter, + instructions=_triangle_line(REFERENCE_PITCH), + read_pitch=_read_pitch, + arpeggio=np.array([OCTAVE, 0], dtype=np.int8), + edited_pitches=[REFERENCE_PITCH + OCTAVE] + [REFERENCE_PITCH] * SOUNDING_FRAMES, + expected=REFERENCE_PITCH, + ), + TestCase( + label="noise", + exporter=NoiseExporter, + instructions=_noise_line(REFERENCE_PERIOD), + read_pitch=_read_period, + arpeggio=np.array([PERIOD_STEP, 0], dtype=np.int8), + edited_pitches=[REFERENCE_PERIOD + PERIOD_STEP] + [REFERENCE_PERIOD] * SOUNDING_FRAMES, + expected=REFERENCE_PERIOD, + ), + ] + + @staticmethod + def _export(test_case: TestCase, instructions: Sequence[InstructionUnion]) -> Features: + return test_case.exporter().to_features(list(instructions), test_case.expected) + + @classmethod + def _edited(cls, test_case: TestCase) -> List[InstructionUnion]: + features = cls._export(test_case, test_case.instructions) + features[FeatureKey.ARPEGGIO] = test_case.arpeggio + return test_case.exporter.from_features(features) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_derived_reference_is_the_contour_pitch(self, test_case: TestCase) -> None: + assert test_case.exporter.derive_initial_pitch(list(test_case.instructions)) == test_case.expected + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_flat_contour_exports_a_zero_offset(self, test_case: TestCase) -> None: + features = self._export(test_case, test_case.instructions) + + assert features.initial_pitch == test_case.expected + assert features.arpeggio.tolist() == [0] + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_edited_arpeggio_offsets_every_frame_from_the_reference(self, test_case: TestCase) -> None: + """The envelope's final value carries over the frames beyond it, as an offset. + + A two-item envelope describes a channel that sounds for longer, so the frames past + its end repeat its last offset. They land on the reference, rather than accumulating + a step per frame. + """ + instructions = self._edited(test_case) + + assert [test_case.read_pitch(instruction) for instruction in instructions] == test_case.edited_pitches + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_re_export_keeps_the_stored_reference(self, test_case: TestCase) -> None: + features = self._export(test_case, self._edited(test_case)) + + assert features.initial_pitch == test_case.expected + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_re_export_reads_the_edited_arpeggio_back(self, test_case: TestCase) -> None: + features = self._export(test_case, self._edited(test_case)) + + assert features.arpeggio.tolist() == test_case.arpeggio.tolist() + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_cleared_arpeggio_returns_every_frame_to_the_reference(self, test_case: TestCase) -> None: + """Clearing an arpeggio envelope restores the pitch the channel started at. + + This is the reported behaviour: typing ``12 0`` and then clearing it back to ``0`` + sounds the sample at the note it was reconstructed at. + """ + features = self._export(test_case, self._edited(test_case)) + features[FeatureKey.ARPEGGIO] = np.zeros(len(test_case.arpeggio), dtype=np.int8) + + cleared = test_case.exporter.from_features(features) + + pitches = [test_case.read_pitch(instruction) for instruction in cleared] + assert pitches == [test_case.expected] * len(cleared) + assert len(cleared) == len(test_case.edited_pitches) + + +class TestAbsentArpeggioEnvelope(BaseTestSuite): + """An arpeggio envelope covering no frame sounds the whole sequence at its reference pitch.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + expected: int + exporter: ExporterTypeUnion + features: Features + read_pitch: Callable[[Any], int] + + test_cases = [ + TestCase( + label="pulse", + exporter=PulseExporter, + features=Features( + initial_pitch=REFERENCE_PITCH, + volume=np.array([PULSE_VOLUME, PULSE_VOLUME, 0], dtype=np.int8), + arpeggio=np.array([], dtype=np.int8), + pitch=None, + hi_pitch=None, + duty_cycle=np.array([0], dtype=np.int8), + ), + read_pitch=_read_pitch, + expected=REFERENCE_PITCH, + ), + TestCase( + label="triangle", + exporter=TriangleExporter, + features=Features( + initial_pitch=REFERENCE_PITCH, + volume=np.array([15, 15, 0], dtype=np.int8), + arpeggio=np.array([], dtype=np.int8), + pitch=None, + hi_pitch=None, + duty_cycle=None, + ), + read_pitch=_read_pitch, + expected=REFERENCE_PITCH, + ), + TestCase( + label="noise", + exporter=NoiseExporter, + features=Features( + initial_pitch=REFERENCE_PERIOD, + volume=np.array([NOISE_VOLUME, NOISE_VOLUME, 0], dtype=np.int8), + arpeggio=np.array([], dtype=np.int8), + pitch=None, + hi_pitch=None, + duty_cycle=np.array([0], dtype=np.int8), + ), + read_pitch=_read_period, + expected=REFERENCE_PERIOD, + ), + ] + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_every_frame_sounds_at_the_reference(self, test_case: TestCase) -> None: + instructions = test_case.exporter.from_features(test_case.features) + + pitches = [test_case.read_pitch(instruction) for instruction in instructions] + assert pitches == [test_case.expected] * len(test_case.features.volume) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_audible_frames_stay_audible(self, test_case: TestCase) -> None: + instructions = test_case.exporter.from_features(test_case.features) + + assert instructions[0].on is True + assert instructions[-1].on is False diff --git a/tests/unit/sampletones_core/exporters/test_feature.py b/tests/unit/sampletones_core/exporters/test_feature.py index fac52d2d..192f830e 100644 --- a/tests/unit/sampletones_core/exporters/test_feature.py +++ b/tests/unit/sampletones_core/exporters/test_feature.py @@ -1,11 +1,8 @@ -from pathlib import Path from typing import Optional import numpy as np from sampletones_core.exporters import Features -from sampletones_core.famitracker.sequences.truncation import SequenceTruncation -from sampletones_core.famitracker.specification.sequences import MAX_SEQUENCE_ITEMS def build_features(frames: int, *, duty_cycle_frames: Optional[int] = None) -> Features: @@ -29,19 +26,3 @@ def test_absent_dimensions_leave_the_count_to_the_others(self) -> None: def test_empty_envelopes_count_no_frames(self) -> None: assert build_features(0).frame_count == 0 - - -class TestSaveReportsTruncation: - def test_an_envelope_within_the_limit_reports_nothing(self, tmp_path: Path) -> None: - features = build_features(MAX_SEQUENCE_ITEMS) - assert features.save(tmp_path / "short.fti", "Short") is None - - def test_an_envelope_beyond_the_limit_reports_both_counts(self, tmp_path: Path) -> None: - features = build_features(300) - truncation = features.save(tmp_path / "long.fti", "Long") - assert truncation == SequenceTruncation(frames=MAX_SEQUENCE_ITEMS, source_frames=300) - - def test_a_shortened_export_still_writes_the_file(self, tmp_path: Path) -> None: - filepath = tmp_path / "long.fti" - build_features(300, duty_cycle_frames=300).save(filepath, "Long") - assert filepath.exists() diff --git a/tests/unit/sampletones_core/exporters/test_lengths.py b/tests/unit/sampletones_core/exporters/test_lengths.py new file mode 100644 index 00000000..b3c4bb0c --- /dev/null +++ b/tests/unit/sampletones_core/exporters/test_lengths.py @@ -0,0 +1,83 @@ +import logging +from typing import Dict, Final, Tuple + +import pytest + +from sampletones_core.exporters.lengths import equalize_lengths + +VOLUME: Final[str] = "volume" +ARPEGGIO: Final[str] = "arpeggio" +DUTY: Final[str] = "duty" + +ITEM_LIMIT: Final[int] = 252 + + +def items_of(length: int) -> Tuple[int, ...]: + return tuple(index % 16 for index in range(length)) + + +def volume_and_arpeggio(length: int) -> Dict[str, Tuple[int, ...]]: + return { + VOLUME: items_of(length), + ARPEGGIO: (0,) * length, + DUTY: (), + } + + +class TestEqualizeLengths: + def test_loop_takes_the_shortest_populated_dimension(self) -> None: + equalized = equalize_lengths({VOLUME: (15, 12, 9, 0), ARPEGGIO: (0, 2, 4)}, loop=True) + assert equalized[VOLUME] == (15, 12, 9) + assert equalized[ARPEGGIO] == (0, 2, 4) + + def test_one_shot_holds_the_shorter_dimensions_final_value(self) -> None: + equalized = equalize_lengths({VOLUME: (15, 12, 9, 0), ARPEGGIO: (0, 2, 4)}, loop=False) + assert equalized[VOLUME] == (15, 12, 9, 0) + assert equalized[ARPEGGIO] == (0, 2, 4, 4) + + def test_empty_dimensions_stay_empty(self) -> None: + equalized = equalize_lengths({VOLUME: (15, 12, 0), ARPEGGIO: ()}, loop=False) + assert equalized[ARPEGGIO] == () + + def test_all_dimensions_empty_stay_empty(self) -> None: + equalized = equalize_lengths({VOLUME: (), ARPEGGIO: (), DUTY: ()}, loop=True) + assert all(items == () for items in equalized.values()) + + +class TestItemLimit: + @pytest.mark.parametrize("loop", [False, True], ids=["one_shot", "loop"]) + def test_an_over_long_envelope_keeps_its_opening_items(self, loop: bool) -> None: + length = ITEM_LIMIT + 48 + + equalized = equalize_lengths(volume_and_arpeggio(length), loop=loop, limit=ITEM_LIMIT) + + assert equalized[VOLUME] == items_of(ITEM_LIMIT) + assert len(equalized[ARPEGGIO]) == ITEM_LIMIT + + def test_an_over_long_envelope_is_reported(self, caplog: pytest.LogCaptureFixture) -> None: + with caplog.at_level(logging.WARNING): + equalize_lengths(volume_and_arpeggio(ITEM_LIMIT + 1), loop=False, limit=ITEM_LIMIT) + + assert str(ITEM_LIMIT) in caplog.text + + def test_an_envelope_within_the_limit_is_quiet(self, caplog: pytest.LogCaptureFixture) -> None: + with caplog.at_level(logging.WARNING): + equalize_lengths(volume_and_arpeggio(ITEM_LIMIT), loop=False, limit=ITEM_LIMIT) + + assert caplog.text == "" + + +class TestUnboundedFormat: + def test_an_absent_limit_keeps_every_item(self) -> None: + length = ITEM_LIMIT + 48 + + equalized = equalize_lengths(volume_and_arpeggio(length), loop=False) + + assert equalized[VOLUME] == items_of(length) + assert len(equalized[ARPEGGIO]) == length + + def test_an_absent_limit_is_quiet(self, caplog: pytest.LogCaptureFixture) -> None: + with caplog.at_level(logging.WARNING): + equalize_lengths(volume_and_arpeggio(ITEM_LIMIT + 1), loop=False) + + assert caplog.text == "" diff --git a/tests/unit/sampletones_core/exporters/test_naming.py b/tests/unit/sampletones_core/exporters/test_naming.py new file mode 100644 index 00000000..001e7151 --- /dev/null +++ b/tests/unit/sampletones_core/exporters/test_naming.py @@ -0,0 +1,36 @@ +from dataclasses import dataclass +from typing import Final, List + +import pytest + +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.exporters.naming import instrument_slice_name + +BASE_NAME: Final[str] = "Kick" + + +@dataclass(frozen=True) +class NameCase: + generator: GeneratorName + expected: str + + +NAME_CASES: Final[List[NameCase]] = [ + NameCase(generator=GeneratorName.PULSE1, expected="Kick (pulse1)"), + NameCase(generator=GeneratorName.PULSE2, expected="Kick (pulse2)"), + NameCase(generator=GeneratorName.TRIANGLE, expected="Kick (triangle)"), + NameCase(generator=GeneratorName.NOISE, expected="Kick (noise)"), +] + + +class TestInstrumentSliceName: + @pytest.mark.parametrize("case", NAME_CASES, ids=lambda case: case.generator.value) + def test_the_generator_follows_the_base_name_in_parentheses(self, case: NameCase) -> None: + assert instrument_slice_name(BASE_NAME, case.generator) == case.expected + + def test_every_generator_gets_a_distinct_name(self) -> None: + names = {instrument_slice_name(BASE_NAME, generator) for generator in GeneratorName.items()} + assert len(names) == len(GeneratorName.items()) + + def test_the_base_name_is_carried_verbatim(self) -> None: + assert instrument_slice_name("Lead 2 (alt)", GeneratorName.PULSE1).startswith("Lead 2 (alt) ") diff --git a/tests/unit/sampletones_core/exporters/test_truncation.py b/tests/unit/sampletones_core/exporters/test_truncation.py new file mode 100644 index 00000000..71803658 --- /dev/null +++ b/tests/unit/sampletones_core/exporters/test_truncation.py @@ -0,0 +1,42 @@ +from typing import Final + +import pytest + +from sampletones_core.exporters.truncation import EnvelopeTruncation + +ITEM_LIMIT: Final[int] = 252 + + +class TestEnvelopeTruncationMeasure: + @pytest.mark.parametrize( + "source_frames", + [0, 1, ITEM_LIMIT], + ids=["empty", "single", "at_the_limit"], + ) + def test_an_envelope_within_the_limit_reports_nothing(self, source_frames: int) -> None: + assert EnvelopeTruncation.measure(source_frames, ITEM_LIMIT) is None + + def test_an_envelope_beyond_the_limit_reports_both_counts(self) -> None: + truncation = EnvelopeTruncation.measure(300, ITEM_LIMIT) + assert truncation == EnvelopeTruncation(frames=ITEM_LIMIT, source_frames=300, instruments=1) + + def test_an_unbounded_format_reports_nothing(self) -> None: + assert EnvelopeTruncation.measure(100_000, None) is None + + +class TestEnvelopeTruncationSummarize: + def test_instruments_that_all_fit_report_nothing(self) -> None: + assert EnvelopeTruncation.summarize([None, None]) is None + + def test_an_empty_export_reports_nothing(self) -> None: + assert EnvelopeTruncation.summarize([]) is None + + def test_the_summary_spans_every_shortened_instrument(self) -> None: + summary = EnvelopeTruncation.summarize( + [ + None, + EnvelopeTruncation(frames=ITEM_LIMIT, source_frames=300, instruments=1), + EnvelopeTruncation(frames=ITEM_LIMIT, source_frames=480, instruments=1), + ] + ) + assert summary == EnvelopeTruncation(frames=ITEM_LIMIT, source_frames=480, instruments=2) diff --git a/tests/unit/sampletones_core/famitracker/sequences/test_lengths.py b/tests/unit/sampletones_core/famitracker/sequences/test_lengths.py deleted file mode 100644 index 23bef4cb..00000000 --- a/tests/unit/sampletones_core/famitracker/sequences/test_lengths.py +++ /dev/null @@ -1,76 +0,0 @@ -import logging -from typing import Dict, Tuple - -import pytest - -from sampletones_core.famitracker.sequences.lengths import equalize_lengths -from sampletones_core.famitracker.specification.sequences import ( - MAX_SEQUENCE_ITEMS, - SequenceKind, -) - - -def items_of(length: int) -> Tuple[int, ...]: - return tuple(index % 16 for index in range(length)) - - -def volume_and_arpeggio(length: int) -> Dict[SequenceKind, Tuple[int, ...]]: - return { - SequenceKind.VOLUME: items_of(length), - SequenceKind.ARPEGGIO: (0,) * length, - SequenceKind.PITCH: (), - SequenceKind.HI_PITCH: (), - SequenceKind.DUTY: (), - } - - -class TestEqualizeLengths: - def test_loop_takes_the_shortest_populated_dimension(self) -> None: - equalized = equalize_lengths( - {SequenceKind.VOLUME: (15, 12, 9, 0), SequenceKind.ARPEGGIO: (0, 2, 4)}, - loop=True, - ) - assert equalized[SequenceKind.VOLUME] == (15, 12, 9) - assert equalized[SequenceKind.ARPEGGIO] == (0, 2, 4) - - def test_one_shot_holds_the_shorter_dimensions_final_value(self) -> None: - equalized = equalize_lengths( - {SequenceKind.VOLUME: (15, 12, 9, 0), SequenceKind.ARPEGGIO: (0, 2, 4)}, - loop=False, - ) - assert equalized[SequenceKind.VOLUME] == (15, 12, 9, 0) - assert equalized[SequenceKind.ARPEGGIO] == (0, 2, 4, 4) - - def test_empty_dimensions_stay_empty(self) -> None: - equalized = equalize_lengths( - {SequenceKind.VOLUME: (15, 12, 0), SequenceKind.ARPEGGIO: ()}, - loop=False, - ) - assert equalized[SequenceKind.ARPEGGIO] == () - - def test_all_dimensions_empty_stay_empty(self) -> None: - equalized = equalize_lengths({kind: () for kind in SequenceKind}, loop=True) - assert all(items == () for items in equalized.values()) - - -class TestFamiTrackerItemLimit: - @pytest.mark.parametrize("loop", [False, True], ids=["one_shot", "loop"]) - def test_an_over_long_envelope_keeps_its_opening_items(self, loop: bool) -> None: - length = MAX_SEQUENCE_ITEMS + 48 - - equalized = equalize_lengths(volume_and_arpeggio(length), loop=loop) - - assert equalized[SequenceKind.VOLUME] == items_of(MAX_SEQUENCE_ITEMS) - assert len(equalized[SequenceKind.ARPEGGIO]) == MAX_SEQUENCE_ITEMS - - def test_an_over_long_envelope_is_reported(self, caplog: pytest.LogCaptureFixture) -> None: - with caplog.at_level(logging.WARNING): - equalize_lengths(volume_and_arpeggio(MAX_SEQUENCE_ITEMS + 1), loop=False) - - assert str(MAX_SEQUENCE_ITEMS) in caplog.text - - def test_an_envelope_within_the_limit_is_quiet(self, caplog: pytest.LogCaptureFixture) -> None: - with caplog.at_level(logging.WARNING): - equalize_lengths(volume_and_arpeggio(MAX_SEQUENCE_ITEMS), loop=False) - - assert caplog.text == "" diff --git a/tests/unit/sampletones_core/famitracker/sequences/test_truncation.py b/tests/unit/sampletones_core/famitracker/sequences/test_truncation.py deleted file mode 100644 index ca5fb888..00000000 --- a/tests/unit/sampletones_core/famitracker/sequences/test_truncation.py +++ /dev/null @@ -1,18 +0,0 @@ -import pytest - -from sampletones_core.famitracker.sequences.truncation import SequenceTruncation -from sampletones_core.famitracker.specification.sequences import MAX_SEQUENCE_ITEMS - - -class TestSequenceTruncationMeasure: - @pytest.mark.parametrize( - "source_frames", - [0, 1, MAX_SEQUENCE_ITEMS], - ids=["empty", "single", "at_the_limit"], - ) - def test_an_envelope_within_the_limit_reports_nothing(self, source_frames: int) -> None: - assert SequenceTruncation.measure(source_frames) is None - - def test_an_envelope_beyond_the_limit_reports_both_counts(self) -> None: - truncation = SequenceTruncation.measure(300) - assert truncation == SequenceTruncation(frames=MAX_SEQUENCE_ITEMS, source_frames=300) diff --git a/tests/unit/sampletones_core/features/test_spec.py b/tests/unit/sampletones_core/features/test_spec.py index 7c4cbedf..a1fb2061 100644 --- a/tests/unit/sampletones_core/features/test_spec.py +++ b/tests/unit/sampletones_core/features/test_spec.py @@ -2,7 +2,6 @@ from sampletones_core.exporters.implementation.noise import NoiseExporter from sampletones_core.exporters.implementation.pulse import PulseExporter from sampletones_core.exporters.implementation.triangle import TriangleExporter -from sampletones_core.famitracker.specification.sequences import FEATURE_KEY_TO_SEQUENCE_KIND, SequenceKind from sampletones_core.features import ( FEATURE_DIMENSION_ORDER, GENERATOR_KIND, @@ -10,6 +9,7 @@ supported_features, supports, ) +from sampletones_core.formats.famitracker.specification.sequences import FEATURE_KEY_TO_SEQUENCE_KIND, SequenceKind def test_supported_features_follow_dimension_order() -> None: diff --git a/tests/unit/sampletones_core/formats/bitphase/__init__.py b/tests/unit/sampletones_core/formats/bitphase/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/sampletones_core/formats/bitphase/conftest.py b/tests/unit/sampletones_core/formats/bitphase/conftest.py new file mode 100644 index 00000000..ae7d4078 --- /dev/null +++ b/tests/unit/sampletones_core/formats/bitphase/conftest.py @@ -0,0 +1,49 @@ +from typing import Final, Optional, Sequence + +import numpy as np + +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.exporters.feature import Features +from sampletones_core.trackers.request import InstrumentExport, SampleExport + +NES_FREQUENCY: Final[int] = 60 +REFERENCE_PITCH: Final[int] = 60 + + +def build_features( + volume: Sequence[int], + *, + arpeggio: Optional[Sequence[int]] = None, + duty_cycle: Optional[Sequence[int]] = None, + initial_pitch: int = REFERENCE_PITCH, +) -> Features: + """Builds the envelopes of one generator slice, flat in every dimension left out.""" + contour = np.zeros(len(volume), dtype=int) if arpeggio is None else np.array(arpeggio, dtype=int) + return Features( + initial_pitch=initial_pitch, + volume=np.array(volume, dtype=int), + arpeggio=contour, + pitch=None, + hi_pitch=None, + duty_cycle=None if duty_cycle is None else np.array(duty_cycle, dtype=int), + ) + + +def build_instrument( + name: str, + features: Features, + *, + generator: GeneratorName = GeneratorName.PULSE1, + loop: bool = False, +) -> InstrumentExport: + return InstrumentExport( + name=name, + generator=generator, + features=features, + loop=loop, + nes_frequency=NES_FREQUENCY, + ) + + +def build_sample(name: str, *instruments: InstrumentExport) -> SampleExport: + return SampleExport(name=name, instruments=instruments, nes_frequency=NES_FREQUENCY) diff --git a/tests/unit/sampletones_core/formats/bitphase/test_btp.py b/tests/unit/sampletones_core/formats/bitphase/test_btp.py new file mode 100644 index 00000000..21b5ba4d --- /dev/null +++ b/tests/unit/sampletones_core/formats/bitphase/test_btp.py @@ -0,0 +1,170 @@ +import gzip +import json +from pathlib import Path +from typing import Any, Dict, Final, List + +import pytest + +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.formats.bitphase.btp import project_to_bytes, write_btp +from sampletones_core.formats.bitphase.builder import sample_to_bitphase +from sampletones_core.formats.bitphase.model.project import BitphaseProject +from sampletones_core.formats.bitphase.specification.chip import CHIP_TYPE_NES, TUNING_TABLE_LENGTH +from sampletones_core.paths import EXT_FILE_BITPHASE + +from .conftest import build_features, build_instrument, build_sample + +VOLUME_ENVELOPE: Final[List[int]] = [15, 10, 5, 0] +PITCH_CONTOUR: Final[List[int]] = [0, 3, 7, 12] + +PROJECT_KEYS: Final[List[str]] = [ + "name", + "author", + "songs", + "loopPointId", + "patternOrder", + "tables", + "patternOrderColors", + "instruments", +] +SONG_KEYS: Final[List[str]] = [ + "patterns", + "tuningTable", + "initialSpeed", + "chipType", + "chipVariant", + "chipFrequency", + "interruptFrequency", + "a4TuningHz", + "virtualChannelMap", +] +PATTERN_KEYS: Final[List[str]] = ["id", "length", "channels", "patternRows"] +ROW_KEYS: Final[List[str]] = ["note", "effects", "instrument", "table", "volume"] +INSTRUMENT_KEYS: Final[List[str]] = ["id", "chipType", "rows", "loop", "name"] +INSTRUMENT_ROW_KEYS: Final[List[str]] = [ + "pulseWidth", + "volumeOrRate", + "retrigger", + "soundLength", + "envelope", + "toneAdd", + "toneAccumulation", + "sweep", + "sweepRate", + "sweepShift", +] +TABLE_KEYS: Final[List[str]] = ["id", "rows", "loop", "name"] + + +@pytest.fixture(name="project") +def project_fixture() -> BitphaseProject: + return sample_to_bitphase( + build_sample( + "Kick", + build_instrument("Kick (pulse1)", build_features(VOLUME_ENVELOPE, arpeggio=PITCH_CONTOUR)), + build_instrument( + "Kick (noise)", + build_features(VOLUME_ENVELOPE, duty_cycle=[1, 1, 0, 0]), + generator=GeneratorName.NOISE, + ), + ) + ) + + +@pytest.fixture(name="document") +def document_fixture(project: BitphaseProject) -> Dict[str, Any]: + return json.loads(gzip.decompress(project_to_bytes(project))) + + +class TestTheFileIsGzippedJson: + def test_the_bytes_decompress_to_json(self, document: Dict[str, Any]) -> None: + assert isinstance(document, dict) + + def test_writing_the_same_document_twice_yields_the_same_bytes(self, project: BitphaseProject) -> None: + """A fixed timestamp keeps the gzip header stable, so an unchanged document + exports byte-identically and a diff shows only real changes. + """ + assert project_to_bytes(project) == project_to_bytes(project) + + def test_the_file_lands_on_disk(self, project: BitphaseProject, tmp_path: Path) -> None: + destination = tmp_path / f"Kick{EXT_FILE_BITPHASE}" + write_btp(destination, project) + assert json.loads(gzip.decompress(destination.read_bytes()))["name"] == "Kick" + + +class TestTheDocumentCarriesEveryFieldBitphaseReads: + """Bitphase reconstructs a project field by field, falling back to a default for + each one it misses, so a document holding every field loads as it was written. + """ + + @pytest.mark.parametrize("key", PROJECT_KEYS) + def test_the_project_holds_its_field(self, document: Dict[str, Any], key: str) -> None: + assert key in document + + @pytest.mark.parametrize("key", SONG_KEYS) + def test_the_song_holds_its_field(self, document: Dict[str, Any], key: str) -> None: + assert key in document["songs"][0] + + @pytest.mark.parametrize("key", PATTERN_KEYS) + def test_the_pattern_holds_its_field(self, document: Dict[str, Any], key: str) -> None: + assert key in document["songs"][0]["patterns"][0] + + @pytest.mark.parametrize("key", ROW_KEYS) + def test_the_row_holds_its_field(self, document: Dict[str, Any], key: str) -> None: + assert key in document["songs"][0]["patterns"][0]["channels"][0]["rows"][0] + + @pytest.mark.parametrize("key", INSTRUMENT_KEYS) + def test_the_instrument_holds_its_field(self, document: Dict[str, Any], key: str) -> None: + assert key in document["instruments"][0] + + @pytest.mark.parametrize("key", INSTRUMENT_ROW_KEYS) + def test_the_instrument_row_holds_its_field(self, document: Dict[str, Any], key: str) -> None: + assert key in document["instruments"][0]["rows"][0] + + @pytest.mark.parametrize("key", TABLE_KEYS) + def test_the_table_holds_its_field(self, document: Dict[str, Any], key: str) -> None: + assert key in document["tables"][0] + + def test_a_note_names_a_semitone_and_an_octave(self, document: Dict[str, Any]) -> None: + note = document["songs"][0]["patterns"][0]["channels"][0]["rows"][0]["note"] + assert set(note) == {"name", "octave"} + + def test_a_channel_names_the_channel_it_drives(self, document: Dict[str, Any]) -> None: + channel = document["songs"][0]["patterns"][0]["channels"][0] + assert set(channel) == {"rows", "label"} + + +class TestTheDocumentReadsAsNes: + def test_the_song_names_the_chip(self, document: Dict[str, Any]) -> None: + assert document["songs"][0]["chipType"] == CHIP_TYPE_NES + + def test_every_instrument_names_the_chip(self, document: Dict[str, Any]) -> None: + assert {instrument["chipType"] for instrument in document["instruments"]} == {CHIP_TYPE_NES} + + def test_the_tuning_table_covers_every_note_index(self, document: Dict[str, Any]) -> None: + assert len(document["songs"][0]["tuningTable"]) == TUNING_TABLE_LENGTH + + def test_the_order_names_patterns_the_song_holds(self, document: Dict[str, Any]) -> None: + held = {pattern["id"] for pattern in document["songs"][0]["patterns"]} + assert set(document["patternOrder"]) <= held + + +class TestTheEnvelopesSurvive: + def test_the_volume_envelope_crosses_over_whole(self, document: Dict[str, Any]) -> None: + rows = document["instruments"][0]["rows"] + assert [row["volumeOrRate"] for row in rows] == VOLUME_ENVELOPE + + def test_the_pitch_contour_crosses_over_whole(self, document: Dict[str, Any]) -> None: + assert document["tables"][0]["rows"] == PITCH_CONTOUR + + def test_the_noise_mode_reaches_the_waveform_field(self, document: Dict[str, Any]) -> None: + rows = document["instruments"][1]["rows"] + assert [row["pulseWidth"] for row in rows] == [1, 1, 0, 0] + + def test_the_rows_read_their_level_as_a_literal_volume(self, document: Dict[str, Any]) -> None: + rows = document["instruments"][0]["rows"] + assert all(row["envelope"] is False for row in rows) + + def test_the_rows_hold_the_note_for_as_long_as_the_envelope_runs(self, document: Dict[str, Any]) -> None: + rows = document["instruments"][0]["rows"] + assert all(row["soundLength"] == 0 for row in rows) diff --git a/tests/unit/sampletones_core/formats/bitphase/test_builder.py b/tests/unit/sampletones_core/formats/bitphase/test_builder.py new file mode 100644 index 00000000..1ca42272 --- /dev/null +++ b/tests/unit/sampletones_core/formats/bitphase/test_builder.py @@ -0,0 +1,225 @@ +import math +from typing import Final, List + +import pytest + +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.general import NUM_PERIODS +from sampletones_core.formats.bitphase.builder import ( + PREVIEW_REST_PATTERN_ID, + PREVIEW_SPEED, + PREVIEW_TRIGGER_ROW, + instrument_to_bitphase, + sample_to_bitphase, +) +from sampletones_core.formats.bitphase.identifiers import format_instrument_id +from sampletones_core.formats.bitphase.model.project import BitphaseProject +from sampletones_core.formats.bitphase.notes import ( + noise_period_to_note_index, + note_index_to_note_cell, + pitch_to_note_index, +) +from sampletones_core.formats.bitphase.specification.channels import CHANNEL_COUNT, ChannelIndex +from sampletones_core.formats.bitphase.specification.chip import CHIP_TYPE_NES +from sampletones_core.formats.bitphase.specification.instruments import MAX_TABLE_ID, MIN_INSTRUMENT_ID, MIN_TABLE_ID +from sampletones_core.formats.bitphase.specification.patterns import ( + FIRST_PATTERN_ID, + FULL_VOLUME, + MAX_PATTERN_LENGTH, + MIN_PATTERN_LENGTH, + NO_INSTRUMENT_CHANGE, + NO_TABLE_CHANGE, + TABLE_COLUMN_OFFSET, + NoteName, +) + +from .conftest import NES_FREQUENCY, REFERENCE_PITCH, build_features, build_instrument, build_sample + +VOLUME_ENVELOPE: Final[List[int]] = [15, 10, 5, 0] +NOISE_PERIOD: Final[int] = 4 +LONG_ENVELOPE_FRAMES: Final[int] = 4000 + + +@pytest.fixture(name="project") +def project_fixture() -> BitphaseProject: + return sample_to_bitphase( + build_sample( + "Kick", + build_instrument("Kick (pulse1)", build_features(VOLUME_ENVELOPE)), + build_instrument( + "Kick (noise)", + build_features(VOLUME_ENVELOPE, initial_pitch=NOISE_PERIOD), + generator=GeneratorName.NOISE, + ), + ) + ) + + +class TestEverySliceBecomesAVoice: + def test_each_slice_yields_one_instrument(self, project: BitphaseProject) -> None: + assert [instrument.name for instrument in project.instruments] == ["Kick (pulse1)", "Kick (noise)"] + + def test_each_slice_yields_the_table_that_carries_its_contour(self, project: BitphaseProject) -> None: + assert [table.name for table in project.tables] == ["Kick (pulse1)", "Kick (noise)"] + + def test_instruments_are_numbered_from_the_first_the_column_names(self, project: BitphaseProject) -> None: + assert [instrument.id for instrument in project.instruments] == [ + format_instrument_id(MIN_INSTRUMENT_ID), + format_instrument_id(MIN_INSTRUMENT_ID + 1), + ] + + def test_tables_are_numbered_alongside_the_instruments(self, project: BitphaseProject) -> None: + assert [table.id for table in project.tables] == [MIN_TABLE_ID, MIN_TABLE_ID + 1] + + def test_every_instrument_declares_the_chip_whose_rows_it_holds(self, project: BitphaseProject) -> None: + """A document that leaves the chip unnamed loads as an AY instrument, so the + instrument rows would be read under the wrong layout. + """ + assert {instrument.chip_type for instrument in project.instruments} == {CHIP_TYPE_NES} + + def test_the_song_declares_the_chip_it_drives(self, project: BitphaseProject) -> None: + assert project.songs[0].chip_type == CHIP_TYPE_NES + + +class TestThePreviewPattern: + def test_the_pattern_spans_every_channel(self, project: BitphaseProject) -> None: + assert len(project.songs[0].patterns[0].channels) == CHANNEL_COUNT + + def test_each_voice_is_triggered_on_the_channel_it_was_reconstructed_for( + self, + project: BitphaseProject, + ) -> None: + channels = project.songs[0].patterns[0].channels + triggered = { + index: channel.rows[PREVIEW_TRIGGER_ROW].instrument + for index, channel in enumerate(channels) + if channel.rows[PREVIEW_TRIGGER_ROW].instrument != NO_INSTRUMENT_CHANGE + } + assert triggered == { + int(ChannelIndex.SQUARE1): MIN_INSTRUMENT_ID, + int(ChannelIndex.NOISE): MIN_INSTRUMENT_ID + 1, + } + + def test_a_trigger_attaches_the_voice_table(self, project: BitphaseProject) -> None: + row = project.songs[0].patterns[0].channels[int(ChannelIndex.SQUARE1)].rows[PREVIEW_TRIGGER_ROW] + assert row.table == MIN_TABLE_ID + TABLE_COLUMN_OFFSET + + def test_a_trigger_passes_the_instrument_volume_through(self, project: BitphaseProject) -> None: + """Row 15 of the volume table is the identity, so the instrument's own envelope + reaches the channel unscaled. + """ + row = project.songs[0].patterns[0].channels[int(ChannelIndex.SQUARE1)].rows[PREVIEW_TRIGGER_ROW] + assert row.volume == FULL_VOLUME + + def test_a_pitched_trigger_names_the_reconstructed_note(self, project: BitphaseProject) -> None: + row = project.songs[0].patterns[0].channels[int(ChannelIndex.SQUARE1)].rows[PREVIEW_TRIGGER_ROW] + assert row.note == note_index_to_note_cell(pitch_to_note_index(REFERENCE_PITCH)) + + def test_a_noise_trigger_names_the_note_that_selects_its_period(self, project: BitphaseProject) -> None: + row = project.songs[0].patterns[0].channels[int(ChannelIndex.NOISE)].rows[PREVIEW_TRIGGER_ROW] + assert row.note == note_index_to_note_cell(noise_period_to_note_index(NOISE_PERIOD)) + + def test_the_lines_after_the_trigger_leave_the_channel_alone(self, project: BitphaseProject) -> None: + rows = project.songs[0].patterns[0].channels[int(ChannelIndex.SQUARE1)].rows + assert all(row.instrument == NO_INSTRUMENT_CHANGE for row in rows[1:]) + assert all(row.table == NO_TABLE_CHANGE for row in rows[1:]) + + def test_the_pattern_length_stays_within_what_bitphase_holds(self, project: BitphaseProject) -> None: + length = project.songs[0].patterns[0].length + assert MIN_PATTERN_LENGTH <= length <= MAX_PATTERN_LENGTH + + def test_every_channel_of_the_pattern_is_as_long_as_the_pattern(self, project: BitphaseProject) -> None: + pattern = project.songs[0].patterns[0] + assert all(len(channel.rows) == pattern.length for channel in pattern.channels) + + +class TestTheOrderCoversTheLongestInstrument: + """Playback returns to the start of the order, so a document whose order runs out + before its longest instrument does would retrigger the slice mid-note. + """ + + @pytest.fixture(name="long_project") + def long_project_fixture(self) -> BitphaseProject: + return sample_to_bitphase( + build_sample( + "Pad", + build_instrument("Pad (pulse1)", build_features([15] * LONG_ENVELOPE_FRAMES)), + ) + ) + + def test_a_short_slice_plays_from_one_position(self, project: BitphaseProject) -> None: + assert project.pattern_order == (FIRST_PATTERN_ID,) + + def test_a_long_slice_rests_for_as_many_positions_as_it_needs(self, long_project: BitphaseProject) -> None: + pattern_length = long_project.songs[0].patterns[0].length + positions = math.ceil(LONG_ENVELOPE_FRAMES / (pattern_length * PREVIEW_SPEED)) + assert long_project.pattern_order == (FIRST_PATTERN_ID,) + (PREVIEW_REST_PATTERN_ID,) * (positions - 1) + + def test_the_resting_positions_name_a_pattern_the_song_holds(self, long_project: BitphaseProject) -> None: + held = {pattern.id for pattern in long_project.songs[0].patterns} + assert set(long_project.pattern_order) <= held + + def test_a_resting_position_leaves_every_channel_silent(self, long_project: BitphaseProject) -> None: + rest = long_project.songs[0].patterns[PREVIEW_REST_PATTERN_ID] + assert all(row.instrument == NO_INSTRUMENT_CHANGE for channel in rest.channels for row in channel.rows) + + +class TestOneSliceOnItsOwn: + def test_a_single_slice_becomes_a_playable_document(self) -> None: + project = instrument_to_bitphase( + build_instrument("Lead", build_features(VOLUME_ENVELOPE, arpeggio=[0, 3, 5, 7])) + ) + assert len(project.instruments) == 1 + assert len(project.tables) == 1 + + def test_the_document_is_named_after_the_slice(self) -> None: + project = instrument_to_bitphase(build_instrument("Lead", build_features(VOLUME_ENVELOPE))) + assert project.name == "Lead" + + def test_the_engine_tick_rate_carries_the_reconstruction_rate(self) -> None: + project = instrument_to_bitphase(build_instrument("Lead", build_features(VOLUME_ENVELOPE))) + assert project.songs[0].interrupt_frequency == NES_FREQUENCY + + def test_a_noise_slice_reaches_the_noise_channel(self) -> None: + project = instrument_to_bitphase( + build_instrument( + "Hat", + build_features(VOLUME_ENVELOPE, arpeggio=[0, 1, 2, 3], initial_pitch=NOISE_PERIOD), + generator=GeneratorName.NOISE, + ) + ) + row = project.songs[0].patterns[0].channels[int(ChannelIndex.NOISE)].rows[PREVIEW_TRIGGER_ROW] + assert row.note.name != int(NoteName.NONE) + + def test_a_noise_table_holds_offsets_within_one_period_cycle(self) -> None: + project = instrument_to_bitphase( + build_instrument( + "Hat", + build_features(VOLUME_ENVELOPE, arpeggio=[0, -1, -2, -3], initial_pitch=NOISE_PERIOD), + generator=GeneratorName.NOISE, + ) + ) + assert all(0 <= offset < NUM_PERIODS for offset in project.tables[0].rows) + + +class TestCapacityLimits: + """A pattern's table column names one base-36 digit, so a document reaching past + what the column can name is refused rather than written unplayable. + """ + + def test_a_document_filling_the_table_column_is_written(self) -> None: + voices = MAX_TABLE_ID + 1 + request = build_sample( + "Wide", + *(build_instrument(f"Slice {index}", build_features(VOLUME_ENVELOPE)) for index in range(voices)), + ) + assert len(sample_to_bitphase(request).tables) == voices + + def test_a_document_past_the_table_column_is_refused(self) -> None: + voices = MAX_TABLE_ID + 2 + request = build_sample( + "Wider", + *(build_instrument(f"Slice {index}", build_features(VOLUME_ENVELOPE)) for index in range(voices)), + ) + with pytest.raises(ValueError, match="tables"): + sample_to_bitphase(request) diff --git a/tests/unit/sampletones_core/formats/bitphase/test_envelopes.py b/tests/unit/sampletones_core/formats/bitphase/test_envelopes.py new file mode 100644 index 00000000..28e4da5d --- /dev/null +++ b/tests/unit/sampletones_core/formats/bitphase/test_envelopes.py @@ -0,0 +1,182 @@ +from dataclasses import dataclass +from typing import Final, List + +import pytest + +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.general import NUM_PERIODS +from sampletones_core.formats.bitphase.envelopes import ChannelEnvelopes, features_to_envelopes +from sampletones_core.formats.bitphase.specification.instruments import ( + FLAT_PULSE_WIDTH, + LOOP_FROM_START, + NO_TABLE_OFFSET, + NOISE_MODE_LONG, + NOISE_MODE_SHORT, + SILENT_VOLUME, +) + +from .conftest import build_features + + +@dataclass +class PulseWidthCase: + generator: GeneratorName + duty_cycle: int + pulse_width: int + + +PULSE_WIDTH_CASES: List[PulseWidthCase] = [ + PulseWidthCase(generator=GeneratorName.PULSE1, duty_cycle=2, pulse_width=2), + PulseWidthCase(generator=GeneratorName.PULSE2, duty_cycle=3, pulse_width=3), + PulseWidthCase(generator=GeneratorName.TRIANGLE, duty_cycle=3, pulse_width=FLAT_PULSE_WIDTH), + PulseWidthCase(generator=GeneratorName.NOISE, duty_cycle=0, pulse_width=NOISE_MODE_LONG), + PulseWidthCase(generator=GeneratorName.NOISE, duty_cycle=1, pulse_width=NOISE_MODE_SHORT), +] + +VOLUME_ENVELOPE: Final[List[int]] = [15, 12, 8, 4, 0] +PITCH_CONTOUR: Final[List[int]] = [0, 2, 4, 5, 7] + + +class TestRowsCarryTheEnvelopes: + def test_each_volume_item_becomes_one_row(self) -> None: + envelopes = features_to_envelopes( + build_features(VOLUME_ENVELOPE, arpeggio=PITCH_CONTOUR), + GeneratorName.PULSE1, + loop=False, + ) + assert [row.volume_or_rate for row in envelopes.rows] == VOLUME_ENVELOPE + + def test_the_contour_becomes_the_table(self) -> None: + envelopes = features_to_envelopes( + build_features(VOLUME_ENVELOPE, arpeggio=PITCH_CONTOUR), + GeneratorName.PULSE1, + loop=False, + ) + assert list(envelopes.table_rows) == PITCH_CONTOUR + + @pytest.mark.parametrize( + "case", + PULSE_WIDTH_CASES, + ids=lambda case: f"{case.generator}-{case.duty_cycle}", + ) + def test_the_duty_item_reaches_the_field_its_channel_reads(self, case: PulseWidthCase) -> None: + envelopes = features_to_envelopes( + build_features([15], duty_cycle=[case.duty_cycle]), + case.generator, + loop=False, + ) + assert envelopes.rows[0].pulse_width == case.pulse_width + + def test_a_channel_without_a_duty_envelope_plays_one_waveform(self) -> None: + envelopes = features_to_envelopes( + build_features(VOLUME_ENVELOPE), + GeneratorName.TRIANGLE, + loop=False, + ) + assert {row.pulse_width for row in envelopes.rows} == {FLAT_PULSE_WIDTH} + + def test_a_noise_contour_takes_the_offsets_that_move_its_period(self) -> None: + steps = [0, 1, -1, 5] + envelopes = features_to_envelopes( + build_features([15] * len(steps), arpeggio=steps), + GeneratorName.NOISE, + loop=False, + ) + assert list(envelopes.table_rows) == [(-step) % NUM_PERIODS for step in steps] + + +class TestTheDimensionsStayInStep: + """Instrument rows and table rows advance on their own per-tick counters, so a + length they share is what keeps the volume envelope aligned with the pitch contour. + """ + + @pytest.mark.parametrize("loop", [True, False], ids=["looping", "one_shot"]) + def test_the_rows_and_the_table_share_a_length(self, loop: bool) -> None: + envelopes = features_to_envelopes( + build_features(VOLUME_ENVELOPE, arpeggio=PITCH_CONTOUR[:3]), + GeneratorName.PULSE1, + loop=loop, + ) + assert len(envelopes.rows) == len(envelopes.table_rows) + + def test_a_looping_slice_takes_the_shortest_dimension(self) -> None: + envelopes = features_to_envelopes( + build_features(VOLUME_ENVELOPE, arpeggio=PITCH_CONTOUR[:2]), + GeneratorName.PULSE1, + loop=True, + ) + assert len(envelopes.rows) == 2 + + def test_a_one_shot_holds_the_shorter_dimension_to_the_end(self) -> None: + envelopes = features_to_envelopes( + build_features(VOLUME_ENVELOPE, arpeggio=PITCH_CONTOUR[:2]), + GeneratorName.PULSE1, + loop=False, + ) + assert list(envelopes.table_rows) == [0, 2, 2, 2, 2] + + def test_a_slice_without_a_contour_holds_its_note(self) -> None: + envelopes = features_to_envelopes( + build_features(VOLUME_ENVELOPE, arpeggio=[]), + GeneratorName.PULSE1, + loop=False, + ) + assert list(envelopes.table_rows) == [NO_TABLE_OFFSET] * len(VOLUME_ENVELOPE) + + +class TestTheLoopPoint: + def test_a_looping_slice_returns_to_its_first_row(self) -> None: + envelopes = features_to_envelopes( + build_features(VOLUME_ENVELOPE), + GeneratorName.PULSE1, + loop=True, + ) + assert envelopes.loop == LOOP_FROM_START + + def test_a_one_shot_rests_on_its_last_row(self) -> None: + envelopes = features_to_envelopes( + build_features(VOLUME_ENVELOPE), + GeneratorName.PULSE1, + loop=False, + ) + assert envelopes.loop == len(envelopes.rows) - 1 + + def test_a_one_shot_rests_in_silence(self) -> None: + """Playback always returns to the loop row, so a slice that has played through + rests on the note-off item its volume envelope ends with. + """ + envelopes = features_to_envelopes( + build_features(VOLUME_ENVELOPE), + GeneratorName.PULSE1, + loop=False, + ) + assert envelopes.rows[envelopes.loop].volume_or_rate == SILENT_VOLUME + + @pytest.mark.parametrize("loop", [True, False], ids=["looping", "one_shot"]) + def test_the_loop_row_exists_in_both_lists(self, loop: bool) -> None: + envelopes = features_to_envelopes( + build_features(VOLUME_ENVELOPE, arpeggio=PITCH_CONTOUR), + GeneratorName.PULSE1, + loop=loop, + ) + assert envelopes.loop < len(envelopes.rows) + assert envelopes.loop < len(envelopes.table_rows) + + +class TestAnEmptySlice: + """An instrument holds at least one row, so a slice with no volume envelope still + reaches Bitphase as a playable silent instrument. + """ + + @pytest.fixture(name="envelopes") + def envelopes_fixture(self) -> ChannelEnvelopes: + return features_to_envelopes(build_features([]), GeneratorName.PULSE1, loop=False) + + def test_it_holds_one_silent_row(self, envelopes: ChannelEnvelopes) -> None: + assert [row.volume_or_rate for row in envelopes.rows] == [SILENT_VOLUME] + + def test_its_table_holds_one_flat_offset(self, envelopes: ChannelEnvelopes) -> None: + assert envelopes.table_rows == (NO_TABLE_OFFSET,) + + def test_it_loops_on_that_row(self, envelopes: ChannelEnvelopes) -> None: + assert envelopes.loop == LOOP_FROM_START diff --git a/tests/unit/sampletones_core/formats/bitphase/test_identifiers.py b/tests/unit/sampletones_core/formats/bitphase/test_identifiers.py new file mode 100644 index 00000000..ec152295 --- /dev/null +++ b/tests/unit/sampletones_core/formats/bitphase/test_identifiers.py @@ -0,0 +1,44 @@ +from dataclasses import dataclass +from typing import List + +import pytest + +from sampletones_core.formats.bitphase.identifiers import format_instrument_id +from sampletones_core.formats.bitphase.specification.instruments import ( + INSTRUMENT_ID_DIGITS, + MAX_INSTRUMENT_ID, + MIN_INSTRUMENT_ID, +) +from sampletones_core.formats.bitphase.specification.patterns import SYMBOL_BASE + + +@dataclass +class IdentifierCase: + number: int + identifier: str + + +IDENTIFIER_CASES: List[IdentifierCase] = [ + IdentifierCase(number=1, identifier="01"), + IdentifierCase(number=10, identifier="0A"), + IdentifierCase(number=35, identifier="0Z"), + IdentifierCase(number=36, identifier="10"), + IdentifierCase(number=MAX_INSTRUMENT_ID, identifier="ZZ"), +] + + +class TestFormatInstrumentId: + @pytest.mark.parametrize("case", IDENTIFIER_CASES, ids=lambda case: str(case.number)) + def test_a_number_renders_as_its_base36_text(self, case: IdentifierCase) -> None: + assert format_instrument_id(case.number) == case.identifier + + def test_bitphase_parses_the_written_text_back(self) -> None: + """A pattern's instrument column is matched against ``parseInt(id, 36)``, so the + text has to read back as the number the column carries. + """ + for number in range(MIN_INSTRUMENT_ID, MAX_INSTRUMENT_ID + 1): + assert int(format_instrument_id(number), SYMBOL_BASE) == number + + def test_every_identifier_fills_the_column(self) -> None: + widths = {len(format_instrument_id(number)) for number in range(MIN_INSTRUMENT_ID, MAX_INSTRUMENT_ID + 1)} + assert widths == {INSTRUMENT_ID_DIGITS} diff --git a/tests/unit/sampletones_core/formats/bitphase/test_notes.py b/tests/unit/sampletones_core/formats/bitphase/test_notes.py new file mode 100644 index 00000000..2649fece --- /dev/null +++ b/tests/unit/sampletones_core/formats/bitphase/test_notes.py @@ -0,0 +1,122 @@ +from dataclasses import dataclass +from typing import Final, List + +import pytest + +from sampletones_core.constants.general import NUM_PERIODS +from sampletones_core.formats.bitphase.notes import ( + noise_arpeggio_to_table_offset, + noise_period_to_note_index, + note_index_to_note_cell, + pitch_to_note_index, +) +from sampletones_core.formats.bitphase.specification.chip import TUNING_TABLE_LENGTH +from sampletones_core.formats.bitphase.specification.patterns import ( + FIRST_OCTAVE, + MAX_NOTE_INDEX, + MIN_NOTE_INDEX, + NOTE_INDEX_PITCH_OFFSET, + NOTE_RANGE, + NoteName, +) + + +@dataclass +class PitchCase: + pitch: int + index: int + + +@dataclass +class NoteCellCase: + index: int + name: int + octave: int + + +PITCH_CASES: List[PitchCase] = [ + PitchCase(pitch=24, index=0), + PitchCase(pitch=60, index=36), + PitchCase(pitch=119, index=95), + PitchCase(pitch=0, index=0), + PitchCase(pitch=200, index=95), +] + +NOTE_CELL_CASES: List[NoteCellCase] = [ + NoteCellCase(index=0, name=int(NoteName.C), octave=1), + NoteCellCase(index=36, name=int(NoteName.C), octave=4), + NoteCellCase(index=45, name=11, octave=4), + NoteCellCase(index=95, name=int(NoteName.B), octave=8), +] + +LOWEST_STEP: Final[int] = -NUM_PERIODS +HIGHEST_STEP: Final[int] = NUM_PERIODS + + +def bitphase_note_value(name: int, octave: int) -> int: + """The note index Bitphase's pattern processor reads back from a note cell.""" + return name - int(NoteName.C) + (octave - FIRST_OCTAVE) * NOTE_RANGE + + +def bitphase_noise_period(index: int) -> int: + """The noise period Bitphase's playback selects for a note index.""" + return NUM_PERIODS - 1 - index % NUM_PERIODS + + +class TestPitchToNoteIndex: + @pytest.mark.parametrize("case", PITCH_CASES, ids=lambda case: str(case.pitch)) + def test_a_pitch_lands_on_its_tuning_table_index(self, case: PitchCase) -> None: + assert pitch_to_note_index(case.pitch) == case.index + + def test_every_pitch_lands_inside_the_tuning_table(self) -> None: + indices = [pitch_to_note_index(pitch) for pitch in range(-50, 200)] + assert all(MIN_NOTE_INDEX <= index <= MAX_NOTE_INDEX for index in indices) + + def test_the_playable_span_keeps_its_distance_from_the_pitch(self) -> None: + pitches = range(NOTE_INDEX_PITCH_OFFSET, NOTE_INDEX_PITCH_OFFSET + TUNING_TABLE_LENGTH) + assert all(pitch_to_note_index(pitch) == pitch - NOTE_INDEX_PITCH_OFFSET for pitch in pitches) + + +class TestNoteIndexToNoteCell: + @pytest.mark.parametrize("case", NOTE_CELL_CASES, ids=lambda case: str(case.index)) + def test_an_index_names_a_semitone_and_an_octave(self, case: NoteCellCase) -> None: + cell = note_index_to_note_cell(case.index) + assert (cell.name, cell.octave) == (case.name, case.octave) + + def test_bitphase_reads_the_written_index_back(self) -> None: + """Playback resolves a cell to ``name - 2 + (octave - 1) * 12``, which is the + index the tuning table is read at, so the round trip is the note's contract. + """ + for index in range(TUNING_TABLE_LENGTH): + cell = note_index_to_note_cell(index) + assert bitphase_note_value(cell.name, cell.octave) == index + + def test_every_cell_names_a_pitched_semitone(self) -> None: + cells = [note_index_to_note_cell(index) for index in range(TUNING_TABLE_LENGTH)] + assert all(int(NoteName.C) <= cell.name <= int(NoteName.B) for cell in cells) + + +class TestNoisePeriods: + @pytest.mark.parametrize("period", range(NUM_PERIODS)) + def test_a_period_reaches_the_note_index_that_selects_it(self, period: int) -> None: + assert bitphase_noise_period(noise_period_to_note_index(period)) == period + + @pytest.mark.parametrize("period", range(NUM_PERIODS)) + def test_a_base_note_leaves_a_whole_cycle_of_offsets_playable(self, period: int) -> None: + index = noise_period_to_note_index(period) + assert MIN_NOTE_INDEX <= index <= MAX_NOTE_INDEX - (NUM_PERIODS - 1) + + @pytest.mark.parametrize("step", range(LOWEST_STEP, HIGHEST_STEP + 1)) + def test_an_arpeggio_step_moves_the_period_by_that_much(self, step: int) -> None: + """The table offset and the base note together reproduce the period the + reconstruction chose, wrapped into the sixteen the channel holds. + """ + for period in range(NUM_PERIODS): + index = noise_period_to_note_index(period) + noise_arpeggio_to_table_offset(step) + assert bitphase_noise_period(index) == (period + step) % NUM_PERIODS + + @pytest.mark.parametrize("step", range(LOWEST_STEP, HIGHEST_STEP + 1)) + def test_every_reached_note_stays_inside_the_tuning_table(self, step: int) -> None: + offset = noise_arpeggio_to_table_offset(step) + indices = [noise_period_to_note_index(period) + offset for period in range(NUM_PERIODS)] + assert all(MIN_NOTE_INDEX <= index <= MAX_NOTE_INDEX for index in indices) diff --git a/tests/unit/sampletones_core/formats/bitphase/test_preset.py b/tests/unit/sampletones_core/formats/bitphase/test_preset.py new file mode 100644 index 00000000..6643d59b --- /dev/null +++ b/tests/unit/sampletones_core/formats/bitphase/test_preset.py @@ -0,0 +1,113 @@ +import json +from pathlib import Path +from typing import Any, Dict, Final, List + +import pytest + +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.formats.bitphase.model.instrument import BitphaseInstrumentPreset +from sampletones_core.formats.bitphase.notes import pitch_to_note_index +from sampletones_core.formats.bitphase.preset import PRESET_TUNING_TABLE, instrument_to_preset, write_preset +from sampletones_core.formats.bitphase.specification.chip import CHIP_TYPE_NES +from sampletones_core.formats.bitphase.specification.instruments import ( + LOOP_FROM_START, + MAX_TONE_ADD, + MIN_TONE_ADD, + NO_TONE_OFFSET, +) +from sampletones_core.paths import EXT_FILE_JSON + +from .conftest import REFERENCE_PITCH, build_features, build_instrument + +VOLUME_ENVELOPE: Final[List[int]] = [15, 10, 5, 0] +PITCH_CONTOUR: Final[List[int]] = [0, 3, 7, 12] +NOISE_PERIOD: Final[int] = 4 +PRESET_KEYS: Final[List[str]] = ["chipType", "name", "loop", "rows"] + + +@pytest.fixture(name="preset") +def preset_fixture() -> BitphaseInstrumentPreset: + return instrument_to_preset( + build_instrument("Lead", build_features(VOLUME_ENVELOPE, arpeggio=PITCH_CONTOUR)), + ) + + +@pytest.fixture(name="document") +def document_fixture(preset: BitphaseInstrumentPreset, tmp_path: Path) -> Dict[str, Any]: + destination = tmp_path / f"Lead{EXT_FILE_JSON}" + write_preset(destination, preset) + return json.loads(destination.read_text(encoding="utf-8")) + + +class TestThePresetCarriesTheSlice: + def test_it_takes_the_slice_name(self, preset: BitphaseInstrumentPreset) -> None: + assert preset.name == "Lead" + + def test_it_holds_one_row_per_envelope_item(self, preset: BitphaseInstrumentPreset) -> None: + assert [row.volume_or_rate for row in preset.rows] == VOLUME_ENVELOPE + + def test_a_one_shot_rests_on_its_last_row(self, preset: BitphaseInstrumentPreset) -> None: + assert preset.loop == len(preset.rows) - 1 + + def test_a_looping_slice_returns_to_its_first_row(self) -> None: + preset = instrument_to_preset( + build_instrument("Pad", build_features(VOLUME_ENVELOPE), loop=True), + ) + assert preset.loop == LOOP_FROM_START + + +class TestThePitchContourRidesInTheToneOffset: + """A preset carries rows alone, so the movement a table would drive is expressed as + the per-tick period offset each row adds to the note's own period. + """ + + def test_each_row_offsets_the_period_its_semitone_asks_for(self, preset: BitphaseInstrumentPreset) -> None: + base_index = pitch_to_note_index(REFERENCE_PITCH) + base_period = PRESET_TUNING_TABLE[base_index] + expected = [PRESET_TUNING_TABLE[base_index + semitones] - base_period for semitones in PITCH_CONTOUR] + assert [row.tone_add for row in preset.rows] == expected + + def test_the_first_row_plays_the_reconstructed_pitch(self, preset: BitphaseInstrumentPreset) -> None: + assert preset.rows[0].tone_add == NO_TONE_OFFSET + + def test_a_rising_contour_shortens_the_period(self, preset: BitphaseInstrumentPreset) -> None: + offsets = [row.tone_add for row in preset.rows] + assert all(later <= earlier for earlier, later in zip(offsets, offsets[1:])) + + def test_every_offset_fits_the_field(self, preset: BitphaseInstrumentPreset) -> None: + assert all(MIN_TONE_ADD <= row.tone_add <= MAX_TONE_ADD for row in preset.rows) + + def test_a_contour_reaching_past_the_tuning_table_holds_its_edge(self) -> None: + preset = instrument_to_preset( + build_instrument("Sweep", build_features(VOLUME_ENVELOPE, arpeggio=[0, 40, 80, 120])), + ) + assert all(MIN_TONE_ADD <= row.tone_add <= MAX_TONE_ADD for row in preset.rows) + + def test_a_noise_slice_takes_its_period_from_the_note(self) -> None: + preset = instrument_to_preset( + build_instrument( + "Hat", + build_features(VOLUME_ENVELOPE, arpeggio=[0, 1, 2, 3], initial_pitch=NOISE_PERIOD), + generator=GeneratorName.NOISE, + ), + ) + assert {row.tone_add for row in preset.rows} == {NO_TONE_OFFSET} + + +class TestThePresetFile: + @pytest.mark.parametrize("key", PRESET_KEYS) + def test_it_holds_every_field_the_panel_reads(self, document: Dict[str, Any], key: str) -> None: + assert key in document + + def test_it_names_the_chip_whose_rows_it_holds(self, document: Dict[str, Any]) -> None: + assert document["chipType"] == CHIP_TYPE_NES + + def test_it_is_indented_the_way_bitphase_writes_its_own( + self, preset: BitphaseInstrumentPreset, tmp_path: Path + ) -> None: + destination = tmp_path / f"Lead{EXT_FILE_JSON}" + write_preset(destination, preset) + assert '\n "name"' in destination.read_text(encoding="utf-8") + + def test_its_rows_carry_the_field_names_the_panel_reads(self, document: Dict[str, Any]) -> None: + assert {"pulseWidth", "volumeOrRate", "toneAdd"} <= set(document["rows"][0]) diff --git a/tests/unit/sampletones_core/formats/bitphase/test_project_builder.py b/tests/unit/sampletones_core/formats/bitphase/test_project_builder.py new file mode 100644 index 00000000..cfd42ecb --- /dev/null +++ b/tests/unit/sampletones_core/formats/bitphase/test_project_builder.py @@ -0,0 +1,221 @@ +from pathlib import Path +from typing import Dict, Final, List, Mapping, Optional, Sequence + +import numpy as np +import pytest + +from sampletones_core.configs import Config +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.formats.bitphase.builder import project_to_bitphase +from sampletones_core.formats.bitphase.model.project import BitphaseProject +from sampletones_core.formats.bitphase.notes import note_index_to_note_cell, pitch_to_note_index +from sampletones_core.formats.bitphase.specification.channels import ChannelIndex +from sampletones_core.formats.bitphase.specification.patterns import ( + NO_INSTRUMENT_CHANGE, + NO_TABLE_CHANGE, + NO_VOLUME_CHANGE, + SYMBOL_BASE, + TABLE_COLUMN_OFFSET, + NoteName, +) +from sampletones_core.instructions.implementation.pulse import PulseInstruction +from sampletones_core.instructions.implementation.triangle import TriangleInstruction +from sampletones_core.instructions.instruction import Instruction +from sampletones_core.project.instruments.instrument import Instrument +from sampletones_core.project.instruments.note_off import NoteOff +from sampletones_core.project.instruments.sample import Sample +from sampletones_core.project.patterns.channel import Channel +from sampletones_core.project.patterns.pattern import Pattern +from sampletones_core.project.patterns.row import Row +from sampletones_core.project.project import Project +from sampletones_core.project.settings import ProjectSettings +from sampletones_core.project.song import Song +from sampletones_core.reconstructions import Reconstruction +from sampletones_core.structures import IdentifiedCollection + +RECONSTRUCTION_LENGTH: Final[int] = 4 +ROWS_PER_PATTERN: Final[int] = 8 +LEAD_PITCH: Final[int] = 60 +BASS_PITCH: Final[int] = 36 +TRANSPOSE: Final[int] = 5 +ROW_VOLUME: Final[int] = 10 +TRIGGER_ROW: Final[int] = 0 +NOTE_OFF_ROW: Final[int] = 2 +TRANSPOSED_ROW: Final[int] = 4 +EMPTY_ROW: Final[int] = 6 + + +def build_reconstruction(instructions: Mapping[GeneratorName, Sequence[Instruction]]) -> Reconstruction: + approximations = {generator: np.zeros(RECONSTRUCTION_LENGTH, dtype=np.float32) for generator in instructions} + return Reconstruction.create( + approximation=np.zeros(RECONSTRUCTION_LENGTH, dtype=np.float32), + approximations=approximations, + instructions=instructions, + config=Config(), + coefficient=1.0, + audio_filepath=Path("/dev/null"), + ) + + +def pulse_sample(name: str, pitch: int) -> Sample: + instructions = [PulseInstruction(on=True, pitch=pitch, volume=15, duty_cycle=0)] + return Sample(name=name, reconstruction=build_reconstruction({GeneratorName.PULSE1: instructions})) + + +def triangle_sample(name: str, pitch: int) -> Sample: + instructions = [TriangleInstruction(on=True, pitch=pitch)] + return Sample(name=name, reconstruction=build_reconstruction({GeneratorName.TRIANGLE: instructions})) + + +@pytest.fixture(name="lead") +def lead_fixture() -> Sample: + return pulse_sample("Lead", LEAD_PITCH) + + +@pytest.fixture(name="bass") +def bass_fixture() -> Sample: + return triangle_sample("Bass", BASS_PITCH) + + +@pytest.fixture(name="source") +def source_fixture(lead: Sample, bass: Sample) -> Project: + samples: IdentifiedCollection[Sample] = IdentifiedCollection() + for sample in (lead, bass): + samples.append(sample) + + pulse_rows: List[Row] = [Row() for _ in range(ROWS_PER_PATTERN)] + pulse_rows[TRIGGER_ROW] = Row( + command=Instrument(sample_id=lead.id, generator_name=GeneratorName.PULSE1), + transpose=0, + volume=ROW_VOLUME, + ) + pulse_rows[NOTE_OFF_ROW] = Row(command=NoteOff()) + pulse_rows[TRANSPOSED_ROW] = Row( + command=Instrument(sample_id=lead.id, generator_name=GeneratorName.PULSE1), + transpose=TRANSPOSE, + ) + + triangle_rows: List[Row] = [Row() for _ in range(ROWS_PER_PATTERN)] + triangle_rows[TRIGGER_ROW] = Row( + command=Instrument(sample_id=bass.id, generator_name=GeneratorName.TRIANGLE), + transpose=0, + ) + + channels = { + GeneratorName.PULSE1: Channel(generator=GeneratorName.PULSE1, patterns={0: Pattern(rows=pulse_rows)}), + GeneratorName.PULSE2: Channel(generator=GeneratorName.PULSE2, patterns={}), + GeneratorName.TRIANGLE: Channel(generator=GeneratorName.TRIANGLE, patterns={0: Pattern(rows=triangle_rows)}), + GeneratorName.NOISE: Channel(generator=GeneratorName.NOISE, patterns={}), + } + order: List[Dict[GeneratorName, Optional[int]]] = [ + {GeneratorName.PULSE1: 0, GeneratorName.TRIANGLE: 0}, + {GeneratorName.PULSE1: None, GeneratorName.TRIANGLE: 0}, + ] + + project = Project.create(title="Demo", author="Tester", settings=ProjectSettings()) + project.samples = samples + project.song = Song(rows_per_pattern=ROWS_PER_PATTERN, order=order, channels=channels) + return project + + +@pytest.fixture(name="document") +def document_fixture(source: Project) -> BitphaseProject: + return project_to_bitphase(source) + + +class TestTheDocumentCarriesTheProject: + def test_the_title_and_author_cross_over(self, document: BitphaseProject, source: Project) -> None: + assert (document.name, document.author) == (source.info.title, source.info.author) + + def test_the_speed_and_tick_rate_cross_over(self, document: BitphaseProject, source: Project) -> None: + song = document.songs[0] + assert song.initial_speed == source.settings.speed + assert song.interrupt_frequency == source.settings.nes_frequency + + def test_every_sample_slice_becomes_an_instrument(self, document: BitphaseProject) -> None: + assert [instrument.name for instrument in document.instruments] == ["Lead (pulse1)", "Bass (triangle)"] + + +class TestTheOrderFlattens: + """A SampleToNES order frame points each channel at its own pattern, where a Bitphase + order position names one pattern spanning every channel, so each frame becomes a + pattern of its own carrying that frame's channels side by side. + """ + + def test_each_order_frame_becomes_one_pattern(self, document: BitphaseProject, source: Project) -> None: + assert len(document.songs[0].patterns) == len(source.song.order) + + def test_the_order_plays_those_patterns_in_turn(self, document: BitphaseProject, source: Project) -> None: + assert document.pattern_order == tuple(range(len(source.song.order))) + + def test_a_frame_carries_the_channels_it_names(self, document: BitphaseProject) -> None: + pattern = document.songs[0].patterns[0] + triggered = { + index + for index, channel in enumerate(pattern.channels) + if any(row.instrument != NO_INSTRUMENT_CHANGE for row in channel.rows) + } + assert triggered == {int(ChannelIndex.SQUARE1), int(ChannelIndex.TRIANGLE)} + + def test_a_channel_the_frame_leaves_unset_stays_empty(self, document: BitphaseProject) -> None: + pattern = document.songs[0].patterns[1] + rows = pattern.channels[int(ChannelIndex.SQUARE1)].rows + assert all(row.instrument == NO_INSTRUMENT_CHANGE for row in rows) + + def test_every_pattern_is_as_long_as_the_song_declares(self, document: BitphaseProject, source: Project) -> None: + patterns = document.songs[0].patterns + assert all(pattern.length == source.song.rows_per_pattern for pattern in patterns) + + +class TestRowCells: + def test_a_trigger_names_its_instrument_and_table(self, document: BitphaseProject) -> None: + """Bitphase matches the instrument column against ``parseInt(id, 36)``, so the + column and the instrument's own identifier name the same voice. + """ + row = document.songs[0].patterns[0].channels[int(ChannelIndex.SQUARE1)].rows[TRIGGER_ROW] + assert row.instrument == int(document.instruments[0].id, SYMBOL_BASE) + assert row.table == document.tables[0].id + TABLE_COLUMN_OFFSET + + def test_a_trigger_plays_the_pitch_the_slice_was_reconstructed_at(self, document: BitphaseProject) -> None: + row = document.songs[0].patterns[0].channels[int(ChannelIndex.SQUARE1)].rows[TRIGGER_ROW] + assert row.note == note_index_to_note_cell(pitch_to_note_index(LEAD_PITCH)) + + def test_a_transposed_trigger_moves_that_pitch(self, document: BitphaseProject) -> None: + row = document.songs[0].patterns[0].channels[int(ChannelIndex.SQUARE1)].rows[TRANSPOSED_ROW] + assert row.note == note_index_to_note_cell(pitch_to_note_index(LEAD_PITCH + TRANSPOSE)) + + def test_a_row_volume_reaches_the_volume_column(self, document: BitphaseProject) -> None: + row = document.songs[0].patterns[0].channels[int(ChannelIndex.SQUARE1)].rows[TRIGGER_ROW] + assert row.volume == ROW_VOLUME + + def test_a_row_that_sets_no_volume_leaves_the_column_alone(self, document: BitphaseProject) -> None: + row = document.songs[0].patterns[0].channels[int(ChannelIndex.SQUARE1)].rows[TRANSPOSED_ROW] + assert row.volume == NO_VOLUME_CHANGE + + def test_a_note_off_stops_the_channel(self, document: BitphaseProject) -> None: + row = document.songs[0].patterns[0].channels[int(ChannelIndex.SQUARE1)].rows[NOTE_OFF_ROW] + assert row.note.name == int(NoteName.OFF) + assert row.instrument == NO_INSTRUMENT_CHANGE + + def test_a_blank_line_leaves_every_column_alone(self, document: BitphaseProject) -> None: + row = document.songs[0].patterns[0].channels[int(ChannelIndex.SQUARE1)].rows[EMPTY_ROW] + assert row.note.name == int(NoteName.NONE) + assert (row.instrument, row.table, row.volume) == ( + NO_INSTRUMENT_CHANGE, + NO_TABLE_CHANGE, + NO_VOLUME_CHANGE, + ) + + +class TestAnUnbuildableRow: + def test_a_row_naming_a_slice_with_no_instrument_is_refused(self, source: Project, lead: Sample) -> None: + rows: List[Row] = [Row() for _ in range(ROWS_PER_PATTERN)] + rows[TRIGGER_ROW] = Row(command=Instrument(sample_id=lead.id, generator_name=GeneratorName.PULSE2)) + source.song.channels[GeneratorName.PULSE2] = Channel( + generator=GeneratorName.PULSE2, + patterns={0: Pattern(rows=rows)}, + ) + source.song.order[0][GeneratorName.PULSE2] = 0 + + with pytest.raises(ValueError, match="has no instrument"): + project_to_bitphase(source) diff --git a/tests/unit/sampletones_core/formats/bitphase/test_tuning.py b/tests/unit/sampletones_core/formats/bitphase/test_tuning.py new file mode 100644 index 00000000..7c187ec7 --- /dev/null +++ b/tests/unit/sampletones_core/formats/bitphase/test_tuning.py @@ -0,0 +1,106 @@ +from dataclasses import dataclass +from typing import Final, List, Tuple + +import pytest + +from sampletones_core.formats.bitphase.specification.chip import ( + CPU_FREQUENCIES, + DEFAULT_A4_TUNING, + MAX_TUNING_PERIOD, + MIN_TUNING_PERIOD, + TUNING_A4_INDEX, + TUNING_TABLE_LENGTH, + ChipVariant, +) +from sampletones_core.formats.bitphase.tuning import generate_tuning_table + +BITPHASE_NTSC_TABLE: Final[Tuple[int, ...]] = ( + 2047, 2047, 2047, 2047, 2047, 2047, 2047, 2047, 2047, 2034, 1920, 1812, + 1710, 1614, 1524, 1438, 1357, 1281, 1209, 1141, 1077, 1017, 960, 906, + 855, 807, 762, 719, 679, 641, 605, 571, 539, 508, 480, 453, + 428, 404, 381, 360, 339, 320, 302, 285, 269, 254, 240, 226, + 214, 202, 190, 180, 170, 160, 151, 143, 135, 127, 120, 113, + 107, 101, 95, 90, 85, 80, 76, 71, 67, 64, 60, 57, + 53, 50, 48, 45, 42, 40, 38, 36, 34, 32, 30, 28, + 27, 25, 24, 22, 21, 20, 19, 18, 17, 16, 15, 14, +) # fmt: skip + + +@dataclass +class PeriodCase: + variant: ChipVariant + index: int + period: int + + +VARIANT_CASES: List[PeriodCase] = [ + PeriodCase(variant=ChipVariant.NTSC, index=9, period=2034), + PeriodCase(variant=ChipVariant.NTSC, index=45, period=254), + PeriodCase(variant=ChipVariant.NTSC, index=95, period=14), + PeriodCase(variant=ChipVariant.PAL, index=9, period=1889), + PeriodCase(variant=ChipVariant.PAL, index=45, period=236), + PeriodCase(variant=ChipVariant.PAL, index=95, period=13), + PeriodCase(variant=ChipVariant.DENDY, index=9, period=2015), + PeriodCase(variant=ChipVariant.DENDY, index=45, period=252), + PeriodCase(variant=ChipVariant.DENDY, index=95, period=14), +] + +SLOW_CLOCK: Final[int] = 1000 +RAISED_A4_TUNING: Final[float] = 432.0 +RAISED_A4_PERIOD: Final[int] = 259 +NARROW_TIMER_LIMIT: Final[int] = 255 + + +@pytest.fixture(name="ntsc_table") +def ntsc_table_fixture() -> Tuple[int, ...]: + return generate_tuning_table( + CPU_FREQUENCIES[ChipVariant.NTSC], + a4_tuning=DEFAULT_A4_TUNING, + ) + + +class TestTheTableMatchesBitphase: + """The tuning table is the contract with Bitphase: the tracker derives its own from + the same settings, so a document whose table differs plays at a different pitch than + the reconstruction it came from. These numbers come from Bitphase's own generator. + """ + + def test_the_ntsc_table_equals_the_one_bitphase_derives(self, ntsc_table: Tuple[int, ...]) -> None: + assert ntsc_table == BITPHASE_NTSC_TABLE + + @pytest.mark.parametrize("case", VARIANT_CASES, ids=lambda case: f"{case.variant}-{case.index}") + def test_each_system_clock_yields_bitphase_periods(self, case: PeriodCase) -> None: + table = generate_tuning_table(CPU_FREQUENCIES[case.variant], a4_tuning=DEFAULT_A4_TUNING) + assert table[case.index] == case.period + + def test_a_shifted_concert_pitch_moves_the_whole_table(self) -> None: + table = generate_tuning_table( + CPU_FREQUENCIES[ChipVariant.NTSC], + a4_tuning=RAISED_A4_TUNING, + ) + assert table[TUNING_A4_INDEX] == RAISED_A4_PERIOD + + +class TestTableShape: + def test_the_table_covers_every_note_index(self, ntsc_table: Tuple[int, ...]) -> None: + assert len(ntsc_table) == TUNING_TABLE_LENGTH + + def test_a_rising_note_index_shortens_the_period(self, ntsc_table: Tuple[int, ...]) -> None: + assert all(later <= earlier for earlier, later in zip(ntsc_table, ntsc_table[1:])) + + @pytest.mark.parametrize("variant", list(ChipVariant)) + def test_every_period_fits_the_channel_timer(self, variant: ChipVariant) -> None: + table = generate_tuning_table(CPU_FREQUENCIES[variant], a4_tuning=DEFAULT_A4_TUNING) + assert all(MIN_TUNING_PERIOD <= period <= MAX_TUNING_PERIOD for period in table) + + def test_a_clock_too_slow_for_the_top_notes_holds_the_shortest_period(self) -> None: + table = generate_tuning_table(SLOW_CLOCK, a4_tuning=DEFAULT_A4_TUNING) + assert table[-1] == MIN_TUNING_PERIOD + + def test_a_narrower_timer_holds_the_longest_period(self) -> None: + table = generate_tuning_table( + CPU_FREQUENCIES[ChipVariant.NTSC], + a4_tuning=DEFAULT_A4_TUNING, + max_period=NARROW_TIMER_LIMIT, + ) + assert max(table) == NARROW_TIMER_LIMIT diff --git a/tests/unit/sampletones_core/formats/famitracker/__init__.py b/tests/unit/sampletones_core/formats/famitracker/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/sampletones_core/famitracker/conftest.py b/tests/unit/sampletones_core/formats/famitracker/conftest.py similarity index 100% rename from tests/unit/sampletones_core/famitracker/conftest.py rename to tests/unit/sampletones_core/formats/famitracker/conftest.py diff --git a/tests/unit/sampletones_core/formats/famitracker/model/__init__.py b/tests/unit/sampletones_core/formats/famitracker/model/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/sampletones_core/famitracker/model/test_sequence.py b/tests/unit/sampletones_core/formats/famitracker/model/test_sequence.py similarity index 77% rename from tests/unit/sampletones_core/famitracker/model/test_sequence.py rename to tests/unit/sampletones_core/formats/famitracker/model/test_sequence.py index eb81b7a6..967c9bb1 100644 --- a/tests/unit/sampletones_core/famitracker/model/test_sequence.py +++ b/tests/unit/sampletones_core/formats/famitracker/model/test_sequence.py @@ -1,7 +1,7 @@ import pytest -from sampletones_core.famitracker.model.sequence import InstrumentSequence -from sampletones_core.famitracker.specification.sequences import ( +from sampletones_core.formats.famitracker.model.sequence import InstrumentSequence +from sampletones_core.formats.famitracker.specification.sequences import ( MAX_SEQUENCE_ITEMS, SequenceKind, ) diff --git a/tests/unit/sampletones_core/formats/famitracker/sequences/__init__.py b/tests/unit/sampletones_core/formats/famitracker/sequences/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/sampletones_core/famitracker/sequences/test_features.py b/tests/unit/sampletones_core/formats/famitracker/sequences/test_features.py similarity index 97% rename from tests/unit/sampletones_core/famitracker/sequences/test_features.py rename to tests/unit/sampletones_core/formats/famitracker/sequences/test_features.py index 25dd81d4..46d6dabf 100644 --- a/tests/unit/sampletones_core/famitracker/sequences/test_features.py +++ b/tests/unit/sampletones_core/formats/famitracker/sequences/test_features.py @@ -1,8 +1,8 @@ import numpy as np import pytest -from sampletones_core.famitracker.sequences.features import features_to_instrument_sequences -from sampletones_core.famitracker.specification.sequences import ( +from sampletones_core.formats.famitracker.sequences.features import features_to_instrument_sequences +from sampletones_core.formats.famitracker.specification.sequences import ( LOOP_FROM_START, MAX_SEQUENCE_ITEMS, NO_LOOP_POINT, diff --git a/tests/unit/sampletones_core/famitracker/test_binary.py b/tests/unit/sampletones_core/formats/famitracker/test_binary.py similarity index 95% rename from tests/unit/sampletones_core/famitracker/test_binary.py rename to tests/unit/sampletones_core/formats/famitracker/test_binary.py index e672c3ab..bc1c8f21 100644 --- a/tests/unit/sampletones_core/famitracker/test_binary.py +++ b/tests/unit/sampletones_core/formats/famitracker/test_binary.py @@ -4,8 +4,8 @@ import pytest -from sampletones_core.famitracker.binary import BinaryWriter -from sampletones_core.famitracker.specification.blocks import BLOCK_NAME_LENGTH, Block +from sampletones_core.formats.famitracker.binary import BinaryWriter +from sampletones_core.formats.famitracker.specification.blocks import BLOCK_NAME_LENGTH, Block @dataclass diff --git a/tests/unit/sampletones_core/famitracker/test_builder.py b/tests/unit/sampletones_core/formats/famitracker/test_builder.py similarity index 80% rename from tests/unit/sampletones_core/famitracker/test_builder.py rename to tests/unit/sampletones_core/formats/famitracker/test_builder.py index eba3cd4c..cc95bf02 100644 --- a/tests/unit/sampletones_core/famitracker/test_builder.py +++ b/tests/unit/sampletones_core/formats/famitracker/test_builder.py @@ -1,12 +1,13 @@ +import numpy as np import pytest from sampletones_core.constants.enums import GeneratorName -from sampletones_core.famitracker.builder import build_instrument_table, project_to_module -from sampletones_core.famitracker.specification.channels import CHANNEL_COUNT_2A03, ChannelId -from sampletones_core.famitracker.specification.instruments import MAX_INSTRUMENTS -from sampletones_core.famitracker.specification.parameters import EXPANSION_NONE, Machine -from sampletones_core.famitracker.specification.patterns import EMPTY_INSTRUMENT, NoteValue -from sampletones_core.famitracker.specification.sequences import ( +from sampletones_core.formats.famitracker.builder import build_instrument_table, project_to_module +from sampletones_core.formats.famitracker.specification.channels import CHANNEL_COUNT_2A03, ChannelId +from sampletones_core.formats.famitracker.specification.instruments import MAX_INSTRUMENTS +from sampletones_core.formats.famitracker.specification.parameters import EXPANSION_NONE, Machine +from sampletones_core.formats.famitracker.specification.patterns import EMPTY_INSTRUMENT, NoteValue +from sampletones_core.formats.famitracker.specification.sequences import ( LOOP_FROM_START, NO_LOOP_POINT, SequenceKind, @@ -15,7 +16,10 @@ from sampletones_core.project.instruments.sample import Sample from sampletones_core.project.project import Project -from .conftest import ProjectFixture, build_reconstruction +from .conftest import RECONSTRUCTION_LENGTH, ProjectFixture, build_reconstruction + +LEAD_PITCH = 60 +OCTAVE = 12 class TestBuildInstrumentTable: @@ -31,7 +35,31 @@ def test_slot_maps_sample_and_generator_to_index(self, project_fixture: ProjectF def test_slot_carries_initial_pitch(self, project_fixture: ProjectFixture) -> None: _, slots = build_instrument_table(project_fixture.project) - assert slots[(project_fixture.lead.id, GeneratorName.PULSE1)].initial_pitch == 60 + assert slots[(project_fixture.lead.id, GeneratorName.PULSE1)].initial_pitch == LEAD_PITCH + + def test_slot_keeps_its_pitch_after_an_arpeggio_edit(self, project_fixture: ProjectFixture) -> None: + """A pattern row triggers the instrument at the note its sample was reconstructed at. + + Raising a channel's first frame an octave moves the arpeggio sequence, and the row + keeps naming the reference pitch — so the tracker plays the contour the reconstruction + view sounds. + """ + arpeggiated = [ + PulseInstruction(on=True, pitch=LEAD_PITCH + OCTAVE, volume=15, duty_cycle=0), + PulseInstruction(on=True, pitch=LEAD_PITCH, volume=8, duty_cycle=0), + ] + project_fixture.lead.reconstruction.update_generator_data( + GeneratorName.PULSE1, + arpeggiated, + np.ones(RECONSTRUCTION_LENGTH, dtype=np.float32), + LEAD_PITCH, + ) + + instruments, slots = build_instrument_table(project_fixture.project) + + slot = slots[(project_fixture.lead.id, GeneratorName.PULSE1)] + assert slot.initial_pitch == LEAD_PITCH + assert list(instruments[slot.index].sequences[SequenceKind.ARPEGGIO].items)[0] == OCTAVE def test_looping_sample_loops_populated_sequences(self, project_fixture: ProjectFixture) -> None: instruments, slots = build_instrument_table(project_fixture.project) diff --git a/tests/unit/sampletones_core/famitracker/test_fti.py b/tests/unit/sampletones_core/formats/famitracker/test_fti.py similarity index 96% rename from tests/unit/sampletones_core/famitracker/test_fti.py rename to tests/unit/sampletones_core/formats/famitracker/test_fti.py index 21d5dae9..dd59b6b0 100644 --- a/tests/unit/sampletones_core/famitracker/test_fti.py +++ b/tests/unit/sampletones_core/formats/famitracker/test_fti.py @@ -5,9 +5,9 @@ import numpy as np -from sampletones_core.famitracker.fti import write_fti -from sampletones_core.famitracker.model.instrument import Instrument2A03 -from sampletones_core.famitracker.sequences.features import features_to_instrument_sequences +from sampletones_core.formats.famitracker.instrument import write_fti +from sampletones_core.formats.famitracker.model.instrument import Instrument2A03 +from sampletones_core.formats.famitracker.sequences.features import features_to_instrument_sequences GOLDEN_INSTRUMENT_NAME = "Test Instrument" GOLDEN_VOLUME = np.array([15, 12, 8, 0]) diff --git a/tests/unit/sampletones_core/famitracker/test_ftm.py b/tests/unit/sampletones_core/formats/famitracker/test_ftm.py similarity index 91% rename from tests/unit/sampletones_core/famitracker/test_ftm.py rename to tests/unit/sampletones_core/formats/famitracker/test_ftm.py index 15b8c2d4..1abf9360 100644 --- a/tests/unit/sampletones_core/famitracker/test_ftm.py +++ b/tests/unit/sampletones_core/formats/famitracker/test_ftm.py @@ -1,9 +1,9 @@ from pathlib import Path -from sampletones_core.famitracker.builder import project_to_module -from sampletones_core.famitracker.export import write_ftm -from sampletones_core.famitracker.ftm import module_to_ftm_bytes -from sampletones_core.famitracker.specification.blocks import ( +from sampletones_core.formats.famitracker.builder import project_to_module +from sampletones_core.formats.famitracker.export import write_ftm +from sampletones_core.formats.famitracker.module import module_to_ftm_bytes +from sampletones_core.formats.famitracker.specification.blocks import ( BLOCK_COMMENTS, BLOCK_DPCM_SAMPLES, BLOCK_FRAMES, @@ -14,19 +14,19 @@ BLOCK_PATTERNS, BLOCK_SEQUENCES, ) -from sampletones_core.famitracker.specification.channels import ChannelId -from sampletones_core.famitracker.specification.file import FTM_END_MARKER, FTM_VERSION -from sampletones_core.famitracker.specification.parameters import ( +from sampletones_core.formats.famitracker.specification.channels import ChannelId +from sampletones_core.formats.famitracker.specification.file import FTM_END_MARKER, FTM_VERSION +from sampletones_core.formats.famitracker.specification.parameters import ( DEFAULT_SPEED_SPLIT_POINT, EXPANSION_NONE, Machine, ) -from sampletones_core.famitracker.specification.patterns import ( +from sampletones_core.formats.famitracker.specification.patterns import ( EMPTY_INSTRUMENT, EMPTY_VOLUME, NoteValue, ) -from sampletones_core.famitracker.specification.sequences import SequenceKind +from sampletones_core.formats.famitracker.specification.sequences import SequenceKind from tests.suite.famitracker import ParsedModule, ParsedSequence, parse_ftm from .conftest import ProjectFixture @@ -121,8 +121,8 @@ def test_all_instruments_are_2a03(self, project_fixture: ProjectFixture) -> None def test_instrument_names_include_generator(self, project_fixture: ProjectFixture) -> None: names = [instrument.name for instrument in _parsed(project_fixture).instruments] - assert names[0] == "lead Pulse 1" - assert "Triangle" in names[4] + assert names[0] == "lead (pulse1)" + assert names[4] == "bell (triangle)" def test_volume_reference_resolves_to_populated_sequence(self, project_fixture: ProjectFixture) -> None: parsed = _parsed(project_fixture) diff --git a/tests/unit/sampletones_core/famitracker/test_notes.py b/tests/unit/sampletones_core/formats/famitracker/test_notes.py similarity index 94% rename from tests/unit/sampletones_core/famitracker/test_notes.py rename to tests/unit/sampletones_core/formats/famitracker/test_notes.py index 962ee4c8..8abfb321 100644 --- a/tests/unit/sampletones_core/famitracker/test_notes.py +++ b/tests/unit/sampletones_core/formats/famitracker/test_notes.py @@ -3,12 +3,12 @@ import pytest -from sampletones_core.famitracker.notes import ( +from sampletones_core.formats.famitracker.notes import ( period_to_note_cell, pitch_to_note_cell, resolve_machine, ) -from sampletones_core.famitracker.specification.parameters import ( +from sampletones_core.formats.famitracker.specification.parameters import ( ENGINE_SPEED_MACHINE_DEFAULT, Machine, ) diff --git a/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py b/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py index 64c2d098..df570d85 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py @@ -1,12 +1,15 @@ from dataclasses import dataclass from pathlib import Path -from typing import Callable, Final +from typing import Callable, Final, List from unittest.mock import patch +import numpy as np import pytest +from sampletones_core.configs import Config from sampletones_core.constants.enums import GeneratorName from sampletones_core.data import Metadata +from sampletones_core.instructions import PulseInstruction from sampletones_core.reconstructions import Reconstruction from sampletones_shared.application import ( SAMPLETONES_RECONSTRUCTION_DATA_VERSION, @@ -28,6 +31,27 @@ _RETUNED_FREQUENCY: Final[int] = 60 _FASTER_FREQUENCY: Final[int] = 120 +_AUDIO_LENGTH: Final[int] = 64 +_BASE_PITCH: Final[int] = 60 +_OCTAVE: Final[int] = 12 +_CONTOUR_MIDPOINT: Final[int] = 66 +_RESET_PITCH: Final[int] = 48 + + +def _pulse(pitch: int) -> PulseInstruction: + return PulseInstruction(on=True, pitch=pitch, volume=8, duty_cycle=0) + + +def _reconstruction(instructions: List[PulseInstruction]) -> Reconstruction: + return Reconstruction.create( + approximation=np.zeros(_AUDIO_LENGTH, dtype=np.float32), + approximations={GeneratorName.PULSE1: np.zeros(_AUDIO_LENGTH, dtype=np.float32)}, + instructions={GeneratorName.PULSE1: instructions}, + config=Config(), + coefficient=1.0, + audio_filepath=Path("/dev/null"), + ) + class TestRoundTrip: def test_save_load_round_trip( @@ -192,6 +216,61 @@ def test_deserialize_data_maps_error(self, test_case: TestCase) -> None: Reconstruction.deserialize_data(b"x", source="mem") +class TestInitialPitchReference: + """The reference pitch each channel's arpeggio is measured against is stored, not re-derived. + + Storing it is what keeps an arpeggio edit from moving the base pitch: an edited contour + carries absolute pitches, so deriving a reference from it again would follow the edit. + """ + + def test_create_anchors_each_generator_to_its_contour(self) -> None: + reconstruction = _reconstruction([_pulse(_BASE_PITCH), _pulse(_BASE_PITCH + _OCTAVE)]) + + assert reconstruction.initial_pitches[GeneratorName.PULSE1] == _CONTOUR_MIDPOINT + + def test_export_measures_the_arpeggio_against_the_stored_reference(self) -> None: + """An arpeggiated channel exports offsets from the pitch it was anchored at. + + The channel is anchored flat at ``_BASE_PITCH`` and then given a contour an octave + up on its first frame — the shape an ``12 0`` envelope produces. The export reports + the stored reference and reads the octave straight back. + """ + reconstruction = _reconstruction([_pulse(_BASE_PITCH)] * 3) + arpeggiated = [_pulse(_BASE_PITCH + _OCTAVE), _pulse(_BASE_PITCH), _pulse(_BASE_PITCH)] + reconstruction.update_generator_data( + GeneratorName.PULSE1, + arpeggiated, + np.ones(_AUDIO_LENGTH, dtype=np.float32), + _BASE_PITCH, + ) + + features = reconstruction.export()[GeneratorName.PULSE1] + + assert features.initial_pitch == _BASE_PITCH + assert features.arpeggio.tolist() == [_OCTAVE, 0] + + def test_update_generator_data_replaces_the_reference(self) -> None: + reconstruction = _reconstruction([_pulse(_BASE_PITCH)]) + + reconstruction.update_generator_data( + GeneratorName.PULSE1, + [_pulse(_RESET_PITCH)], + np.ones(_AUDIO_LENGTH, dtype=np.float32), + _RESET_PITCH, + ) + + assert reconstruction.initial_pitches[GeneratorName.PULSE1] == _RESET_PITCH + + def test_reference_survives_a_save_load_round_trip(self, tmp_path: Path) -> None: + reconstruction = _reconstruction([_pulse(_BASE_PITCH), _pulse(_BASE_PITCH + _OCTAVE)]) + path = tmp_path / "anchored.stn" + + reconstruction.save(path) + loaded = Reconstruction.load(path) + + assert loaded.initial_pitches == reconstruction.initial_pitches + + class TestWithNesFrequency: def test_rebuilds_config(self, reconstruction_factory: ReconstructionFactory) -> None: reconstruction = reconstruction_factory() diff --git a/tests/unit/sampletones_core/trackers/__init__.py b/tests/unit/sampletones_core/trackers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/sampletones_core/trackers/test_bitphase.py b/tests/unit/sampletones_core/trackers/test_bitphase.py new file mode 100644 index 00000000..9d3fca4c --- /dev/null +++ b/tests/unit/sampletones_core/trackers/test_bitphase.py @@ -0,0 +1,222 @@ +import gzip +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, Final, List, Optional + +import numpy as np +import pytest + +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.exporters import Features +from sampletones_core.paths import EXT_FILE_BITPHASE, EXT_FILE_JSON +from sampletones_core.project.project import Project +from sampletones_core.project.settings import ProjectSettings +from sampletones_core.trackers.format import TrackerFormat +from sampletones_core.trackers.implementation.bitphase import BitphaseBackend, BitphasePresetBackend +from sampletones_core.trackers.request import InstrumentExport, ProjectExport, SampleExport +from sampletones_core.trackers.scope import ExportScope + +NES_FREQUENCY: Final[int] = 60 +REFERENCE_PITCH: Final[int] = 60 +ENVELOPE_FRAMES: Final[int] = 16 +LONG_ENVELOPE_FRAMES: Final[int] = 600 +PROJECT_TITLE: Final[str] = "Demo" + + +PRESET_SCOPES: List[ExportScope] = [ExportScope.INSTRUMENT, ExportScope.SAMPLE] + + +def build_features(frames: int, *, duty_cycle_frames: Optional[int] = None) -> Features: + duty_cycle = None if duty_cycle_frames is None else np.zeros(duty_cycle_frames, dtype=int) + return Features( + initial_pitch=REFERENCE_PITCH, + volume=np.full(frames, 15, dtype=int), + arpeggio=np.zeros(frames, dtype=int), + pitch=None, + hi_pitch=None, + duty_cycle=duty_cycle, + ) + + +def build_instrument(name: str, frames: int) -> InstrumentExport: + return InstrumentExport( + name=name, + generator=GeneratorName.PULSE1, + features=build_features(frames), + loop=False, + nes_frequency=NES_FREQUENCY, + ) + + +def build_sample(name: str, *instruments: InstrumentExport) -> SampleExport: + return SampleExport(name=name, instruments=instruments, nes_frequency=NES_FREQUENCY) + + +def read_document(destination: Path) -> Dict[str, Any]: + document: Dict[str, Any] = json.loads(gzip.decompress(destination.read_bytes())) + return document + + +@pytest.fixture(name="backend") +def backend_fixture() -> BitphaseBackend: + return BitphaseBackend() + + +@pytest.fixture(name="preset_backend") +def preset_backend_fixture() -> BitphasePresetBackend: + return BitphasePresetBackend() + + +@pytest.fixture(name="project") +def project_fixture() -> Project: + return Project.create(title=PROJECT_TITLE, author="Tester", settings=ProjectSettings()) + + +class TestFormatDeclaration: + def test_the_backend_names_its_format(self, backend: BitphaseBackend) -> None: + assert backend.tracker_format == TrackerFormat.BITPHASE + + def test_every_scope_is_supported(self, backend: BitphaseBackend) -> None: + assert backend.supported_scopes == frozenset(ExportScope) + + @pytest.mark.parametrize("scope", list(ExportScope)) + def test_every_scope_carries_the_document_extension(self, backend: BitphaseBackend, scope: ExportScope) -> None: + assert backend.extension(scope) == EXT_FILE_BITPHASE + + +class TestWriteInstrument: + def test_the_file_is_written_and_reported(self, backend: BitphaseBackend, tmp_path: Path) -> None: + destination = tmp_path / f"Short{EXT_FILE_BITPHASE}" + + artifact = backend.write_instrument(destination, build_instrument("Short", ENVELOPE_FRAMES)) + + assert destination.exists() + assert artifact.paths == (destination,) + + def test_the_document_holds_the_slice(self, backend: BitphaseBackend, tmp_path: Path) -> None: + destination = tmp_path / f"Short{EXT_FILE_BITPHASE}" + backend.write_instrument(destination, build_instrument("Short", ENVELOPE_FRAMES)) + + document = read_document(destination) + + assert [instrument["name"] for instrument in document["instruments"]] == ["Short"] + + def test_a_long_envelope_crosses_over_whole(self, backend: BitphaseBackend, tmp_path: Path) -> None: + """Bitphase stores instrument rows without a length limit, so a reconstruction + reaches the document at its full length. + """ + destination = tmp_path / f"Long{EXT_FILE_BITPHASE}" + artifact = backend.write_instrument(destination, build_instrument("Long", LONG_ENVELOPE_FRAMES)) + + document = read_document(destination) + + assert len(document["instruments"][0]["rows"]) == LONG_ENVELOPE_FRAMES + assert artifact.truncation is None + + +class TestWriteSample: + def test_every_slice_lands_in_one_document(self, backend: BitphaseBackend, tmp_path: Path) -> None: + destination = tmp_path / f"Kick{EXT_FILE_BITPHASE}" + request = build_sample( + "Kick", + build_instrument("Kick (pulse1)", ENVELOPE_FRAMES), + build_instrument("Kick (noise)", ENVELOPE_FRAMES), + ) + + artifact = backend.write_sample(destination, request) + + assert artifact.paths == (destination,) + assert len(read_document(destination)["instruments"]) == 2 + + def test_the_document_is_named_after_the_reconstruction(self, backend: BitphaseBackend, tmp_path: Path) -> None: + destination = tmp_path / f"Kick{EXT_FILE_BITPHASE}" + backend.write_sample(destination, build_sample("Kick", build_instrument("Kick", ENVELOPE_FRAMES))) + + assert read_document(destination)["name"] == "Kick" + + +class TestWriteProject: + def test_the_document_is_written_and_reported( + self, + backend: BitphaseBackend, + project: Project, + tmp_path: Path, + ) -> None: + destination = tmp_path / f"Demo{EXT_FILE_BITPHASE}" + + artifact = backend.write_project(destination, ProjectExport(project=project)) + + assert artifact.paths == (destination,) + assert destination.exists() + + def test_the_document_takes_the_project_title( + self, + backend: BitphaseBackend, + project: Project, + tmp_path: Path, + ) -> None: + destination = tmp_path / f"Demo{EXT_FILE_BITPHASE}" + backend.write_project(destination, ProjectExport(project=project)) + + assert read_document(destination)["name"] == PROJECT_TITLE + + +class TestThePresetBackend: + def test_the_backend_names_its_format(self, preset_backend: BitphasePresetBackend) -> None: + assert preset_backend.tracker_format == TrackerFormat.BITPHASE_PRESET + + def test_a_preset_holds_instruments_rather_than_a_song(self, preset_backend: BitphasePresetBackend) -> None: + assert preset_backend.supported_scopes == frozenset({ExportScope.INSTRUMENT, ExportScope.SAMPLE}) + + @pytest.mark.parametrize("scope", PRESET_SCOPES, ids=lambda scope: str(scope)) + def test_every_supported_scope_carries_the_preset_extension( + self, + preset_backend: BitphasePresetBackend, + scope: ExportScope, + ) -> None: + assert preset_backend.extension(scope) == EXT_FILE_JSON + + def test_one_slice_lands_in_a_file(self, preset_backend: BitphasePresetBackend, tmp_path: Path) -> None: + destination = tmp_path / f"Lead{EXT_FILE_JSON}" + + artifact = preset_backend.write_instrument(destination, build_instrument("Lead", ENVELOPE_FRAMES)) + + assert artifact.paths == (destination,) + assert json.loads(destination.read_text(encoding="utf-8"))["name"] == "Lead" + + def test_each_slice_lands_beside_the_destination_named_after_its_instrument( + self, + preset_backend: BitphasePresetBackend, + tmp_path: Path, + ) -> None: + destination = tmp_path / f"Kick{EXT_FILE_JSON}" + request = build_sample( + "Kick", + build_instrument("Kick (pulse1)", ENVELOPE_FRAMES), + build_instrument("Kick (noise)", ENVELOPE_FRAMES), + ) + + artifact = preset_backend.write_sample(destination, request) + + assert artifact.paths == ( + tmp_path / f"Kick (pulse1){EXT_FILE_JSON}", + tmp_path / f"Kick (noise){EXT_FILE_JSON}", + ) + assert all(path.exists() for path in artifact.paths) + + def test_a_missing_directory_is_created(self, preset_backend: BitphasePresetBackend, tmp_path: Path) -> None: + destination = tmp_path / "nested" / f"Kick{EXT_FILE_JSON}" + + preset_backend.write_sample(destination, build_sample("Kick", build_instrument("Kick", ENVELOPE_FRAMES))) + + assert destination.parent.is_dir() + + def test_a_project_is_refused( + self, + preset_backend: BitphasePresetBackend, + project: Project, + tmp_path: Path, + ) -> None: + with pytest.raises(ValueError, match="one instrument"): + preset_backend.write_project(tmp_path / f"Demo{EXT_FILE_JSON}", ProjectExport(project=project)) diff --git a/tests/unit/sampletones_core/trackers/test_extensions.py b/tests/unit/sampletones_core/trackers/test_extensions.py new file mode 100644 index 00000000..10ac1651 --- /dev/null +++ b/tests/unit/sampletones_core/trackers/test_extensions.py @@ -0,0 +1,116 @@ +from dataclasses import dataclass +from typing import Dict, Final, List, Optional + +import pytest + +from sampletones_core.paths import ( + EXT_FILE_BITPHASE, + EXT_FILE_INSTRUMENT, + EXT_FILE_JSON, + EXT_FILE_MODULE, +) +from sampletones_core.trackers.backend import TrackerBackend +from sampletones_core.trackers.extensions import format_for_extension +from sampletones_core.trackers.format import TrackerFormat +from sampletones_core.trackers.registry import build_tracker_backends +from sampletones_core.trackers.scope import ExportScope + +UNKNOWN_EXTENSION: Final[str] = ".xm" +NO_EXTENSION: Final[str] = "" + + +@dataclass(frozen=True) +class ExtensionCase: + scope: ExportScope + extension: str + expected: Optional[TrackerFormat] + + +EXTENSION_CASES: Final[List[ExtensionCase]] = [ + ExtensionCase( + scope=ExportScope.INSTRUMENT, + extension=EXT_FILE_INSTRUMENT, + expected=TrackerFormat.FAMITRACKER, + ), + ExtensionCase( + scope=ExportScope.INSTRUMENT, + extension=EXT_FILE_BITPHASE, + expected=TrackerFormat.BITPHASE, + ), + ExtensionCase( + scope=ExportScope.INSTRUMENT, + extension=EXT_FILE_JSON, + expected=TrackerFormat.BITPHASE_PRESET, + ), + ExtensionCase( + scope=ExportScope.SAMPLE, + extension=EXT_FILE_JSON, + expected=TrackerFormat.BITPHASE_PRESET, + ), + ExtensionCase( + scope=ExportScope.PROJECT, + extension=EXT_FILE_MODULE, + expected=TrackerFormat.FAMITRACKER, + ), + ExtensionCase( + scope=ExportScope.INSTRUMENT, + extension=UNKNOWN_EXTENSION, + expected=None, + ), + ExtensionCase( + scope=ExportScope.INSTRUMENT, + extension=NO_EXTENSION, + expected=None, + ), +] + + +@pytest.fixture(name="backends") +def backends_fixture() -> Dict[TrackerFormat, TrackerBackend]: + return build_tracker_backends() + + +class TestFormatForExtension: + @pytest.mark.parametrize( + "case", + EXTENSION_CASES, + ids=lambda case: f"{case.scope}{case.extension}", + ) + def test_the_extension_names_the_format_that_writes_it( + self, + backends: Dict[TrackerFormat, TrackerBackend], + case: ExtensionCase, + ) -> None: + assert format_for_extension(backends, case.scope, case.extension) == case.expected + + def test_an_extension_typed_in_capitals_reaches_the_same_format( + self, + backends: Dict[TrackerFormat, TrackerBackend], + ) -> None: + assert ( + format_for_extension(backends, ExportScope.INSTRUMENT, EXT_FILE_INSTRUMENT.upper()) + == TrackerFormat.FAMITRACKER + ) + + def test_a_format_that_cannot_express_the_scope_stays_unmatched( + self, + backends: Dict[TrackerFormat, TrackerBackend], + ) -> None: + """A preset holds one instrument, so a project named with its extension resolves + to no format at all. + """ + assert format_for_extension(backends, ExportScope.PROJECT, EXT_FILE_JSON) is None + + @pytest.mark.parametrize("scope", list(ExportScope), ids=lambda scope: str(scope)) + def test_every_extension_a_backend_writes_resolves_back_to_it( + self, + backends: Dict[TrackerFormat, TrackerBackend], + scope: ExportScope, + ) -> None: + """A dialog offers the extension of each format it can reach, so a destination taking + one of them names the backend that put it in the selector. Each scope's extensions are + therefore distinct across formats, which is what the resolution reads them as. + """ + for tracker_format, backend in backends.items(): + if scope in backend.supported_scopes: + assert format_for_extension(backends, scope, backend.extension(scope)) == tracker_format diff --git a/tests/unit/sampletones_core/trackers/test_famitracker.py b/tests/unit/sampletones_core/trackers/test_famitracker.py new file mode 100644 index 00000000..9b889d10 --- /dev/null +++ b/tests/unit/sampletones_core/trackers/test_famitracker.py @@ -0,0 +1,157 @@ +from pathlib import Path +from typing import Final, Optional + +import numpy as np +import pytest + +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.exporters import Features +from sampletones_core.exporters.truncation import EnvelopeTruncation +from sampletones_core.formats.famitracker.specification.sequences import MAX_SEQUENCE_ITEMS +from sampletones_core.paths import EXT_FILE_INSTRUMENT, EXT_FILE_MODULE +from sampletones_core.trackers.format import TrackerFormat +from sampletones_core.trackers.implementation.famitracker import FamiTrackerBackend +from sampletones_core.trackers.request import InstrumentExport, SampleExport +from sampletones_core.trackers.scope import ExportScope + +NES_FREQUENCY: Final[int] = 60 + + +def build_features(frames: int, *, duty_cycle_frames: Optional[int] = None) -> Features: + duty_cycle = None if duty_cycle_frames is None else np.zeros(duty_cycle_frames, dtype=int) + return Features( + initial_pitch=60, + volume=np.full(frames, 15, dtype=int), + arpeggio=np.zeros(frames, dtype=int), + pitch=None, + hi_pitch=None, + duty_cycle=duty_cycle, + ) + + +def build_instrument(name: str, frames: int) -> InstrumentExport: + return InstrumentExport( + name=name, + generator=GeneratorName.PULSE1, + features=build_features(frames), + loop=False, + nes_frequency=NES_FREQUENCY, + ) + + +def build_sample(name: str, *instruments: InstrumentExport) -> SampleExport: + return SampleExport(name=name, instruments=instruments, nes_frequency=NES_FREQUENCY) + + +@pytest.fixture(name="backend") +def backend_fixture() -> FamiTrackerBackend: + return FamiTrackerBackend() + + +class TestFormatDeclaration: + def test_the_backend_names_its_format(self, backend: FamiTrackerBackend) -> None: + assert backend.tracker_format == TrackerFormat.FAMITRACKER + + def test_every_scope_is_supported(self, backend: FamiTrackerBackend) -> None: + assert backend.supported_scopes == frozenset(ExportScope) + + @pytest.mark.parametrize( + ("scope", "expected"), + [ + (ExportScope.INSTRUMENT, EXT_FILE_INSTRUMENT), + (ExportScope.SAMPLE, EXT_FILE_INSTRUMENT), + (ExportScope.PROJECT, EXT_FILE_MODULE), + ], + ) + def test_instruments_carry_the_instrument_extension_and_a_project_the_module_one( + self, + backend: FamiTrackerBackend, + scope: ExportScope, + expected: str, + ) -> None: + assert backend.extension(scope) == expected + + +class TestWriteInstrument: + def test_the_file_is_written_and_reported(self, backend: FamiTrackerBackend, tmp_path: Path) -> None: + destination = tmp_path / f"Short{EXT_FILE_INSTRUMENT}" + + artifact = backend.write_instrument(destination, build_instrument("Short", 16)) + + assert destination.exists() + assert artifact.paths == (destination,) + + def test_an_envelope_within_the_limit_reports_nothing(self, backend: FamiTrackerBackend, tmp_path: Path) -> None: + artifact = backend.write_instrument( + tmp_path / f"Short{EXT_FILE_INSTRUMENT}", + build_instrument("Short", MAX_SEQUENCE_ITEMS), + ) + assert artifact.truncation is None + + def test_an_envelope_beyond_the_limit_reports_both_counts( + self, + backend: FamiTrackerBackend, + tmp_path: Path, + ) -> None: + artifact = backend.write_instrument( + tmp_path / f"Long{EXT_FILE_INSTRUMENT}", + build_instrument("Long", 300), + ) + assert artifact.truncation == EnvelopeTruncation( + frames=MAX_SEQUENCE_ITEMS, + source_frames=300, + instruments=1, + ) + + def test_a_shortened_export_still_writes_the_file(self, backend: FamiTrackerBackend, tmp_path: Path) -> None: + destination = tmp_path / f"Long{EXT_FILE_INSTRUMENT}" + backend.write_instrument(destination, build_instrument("Long", 300)) + assert destination.exists() + + +class TestWriteSample: + def test_each_slice_lands_beside_the_destination_named_after_its_instrument( + self, + backend: FamiTrackerBackend, + tmp_path: Path, + ) -> None: + destination = tmp_path / f"Kick{EXT_FILE_INSTRUMENT}" + request = build_sample("Kick", build_instrument("Kick (pulse1)", 16), build_instrument("Kick (noise)", 16)) + + artifact = backend.write_sample(destination, request) + + assert artifact.paths == ( + tmp_path / f"Kick (pulse1){EXT_FILE_INSTRUMENT}", + tmp_path / f"Kick (noise){EXT_FILE_INSTRUMENT}", + ) + assert all(path.exists() for path in artifact.paths) + + def test_a_missing_directory_is_created(self, backend: FamiTrackerBackend, tmp_path: Path) -> None: + destination = tmp_path / "nested" / f"Kick{EXT_FILE_INSTRUMENT}" + + backend.write_sample(destination, build_sample("Kick", build_instrument("Kick", 16))) + + assert destination.parent.is_dir() + + def test_the_report_spans_every_shortened_slice(self, backend: FamiTrackerBackend, tmp_path: Path) -> None: + request = build_sample( + "Kick", + build_instrument("Short", 16), + build_instrument("Long", 300), + build_instrument("Longer", 410), + ) + + artifact = backend.write_sample(tmp_path / "Kick", request) + + assert artifact.truncation == EnvelopeTruncation( + frames=MAX_SEQUENCE_ITEMS, + source_frames=410, + instruments=2, + ) + + def test_slices_that_all_fit_report_nothing(self, backend: FamiTrackerBackend, tmp_path: Path) -> None: + request = build_sample("Kick", build_instrument("Short", 16)) + + artifact = backend.write_sample(tmp_path / "Kick", request) + + assert artifact.truncation is None diff --git a/tests/unit/sampletones_shared/utils/system/test_paths.py b/tests/unit/sampletones_shared/utils/system/test_paths.py index 2b6f494d..2ae5874d 100644 --- a/tests/unit/sampletones_shared/utils/system/test_paths.py +++ b/tests/unit/sampletones_shared/utils/system/test_paths.py @@ -12,6 +12,7 @@ DEFAULT_MAX_FILENAME_DISPLAY, ensure_suffix, get_directory, + get_filename, open_directory_in_explorer_linux, open_file_in_explorer_linux, open_path_in_explorer, @@ -152,6 +153,49 @@ def test_to_path(self, test_case: TestCase) -> None: assert result == Path(test_case.expected) +class TestGetFilename(BaseTestSuite): + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + name: str + extension: str + expected: str + + test_cases = [ + TestCase( + name="song", + extension=".stp", + expected="song.stp", + label="appends_the_extension", + ), + TestCase( + name="Kick (pulse1)", + extension=".fti", + expected="Kick (pulse1).fti", + label="carries_a_parenthesised_slice_name", + ), + TestCase( + name="Kick v1.2", + extension=".fti", + expected="Kick v1.2.fti", + label="keeps_incidental_dots", + ), + TestCase( + name="song.stp", + extension=".stp", + expected="song.stp.stp", + label="appends_to_a_name_already_ending_in_the_extension", + ), + ] + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_get_filename(self, test_case: TestCase) -> None: + assert get_filename(test_case.name, test_case.extension) == test_case.expected + + class TestEnsureSuffix(BaseTestSuite): @dataclass(frozen=True, kw_only=True) class TestCase(BaseRegularTestCase): diff --git a/tests/unit/sampletones_shared/utils/test_arrays.py b/tests/unit/sampletones_shared/utils/test_arrays.py index 6685eb4b..4183aff7 100644 --- a/tests/unit/sampletones_shared/utils/test_arrays.py +++ b/tests/unit/sampletones_shared/utils/test_arrays.py @@ -11,6 +11,7 @@ from sampletones_shared.utils.arrays import ( cast_to_float, clamp, + hold, infer_dtype, interpolate_segment, is_increasing, @@ -1690,6 +1691,120 @@ def test_trim(self, test_case: TestCase) -> None: assert_array_equal(result, test_case.expected) +class TestHold(BaseTestSuite): + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + expected: Union[int, float, Type[Exception]] + array: Any + index: Any + default: Any + + test_cases = [ + TestCase( + array=np.array([12, 5, 0]), + index=0, + default=0, + expected=12, + label="first_frame", + ), + TestCase( + array=np.array([12, 5, 0]), + index=1, + default=0, + expected=5, + label="middle_frame", + ), + TestCase( + array=np.array([12, 5, 7]), + index=2, + default=0, + expected=7, + label="final_frame", + ), + TestCase( + array=np.array([12, 5, 7]), + index=3, + default=0, + expected=7, + label="one_frame_past_the_end_holds_the_final_value", + ), + TestCase( + array=np.array([12, 5, 7]), + index=100, + default=0, + expected=7, + label="far_past_the_end_holds_the_final_value", + ), + TestCase( + array=np.array([4]), + index=9, + default=0, + expected=4, + label="single_frame_envelope_holds_its_only_value", + ), + TestCase( + array=np.array([], dtype=np.int8), + index=0, + default=0, + expected=0, + label="empty_envelope_reads_as_the_default", + ), + TestCase( + array=np.array([], dtype=np.int8), + index=3, + default=7, + expected=7, + label="empty_envelope_reads_as_the_default_at_any_index", + ), + TestCase( + array=np.array([2.5, -1.5]), + index=5, + default=0.0, + expected=-1.5, + label="float_envelope_holds_its_final_value", + ), + TestCase( + array=np.array([1, 2, 3]), + index=-1, + default=0, + expected=ValueError, + label="negative_index_rejected", + ), + TestCase( + array=np.array([[1, 2], [3, 4]]), + index=0, + default=0, + expected=ValueError, + label="array_not_1d", + ), + TestCase( + array=[1, 2, 3], + index=0, + default=0, + expected=TypeError, + label="list_not_array", + ), + ] + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_hold(self, test_case: TestCase) -> None: + if expect_error( + hold, + test_case.expected, + test_case.array, + test_case.index, + default=test_case.default, + ): + return + + result = hold(test_case.array, test_case.index, default=test_case.default) + assert result == test_case.expected + + class TestInterpolateSegment(BaseTestSuite): @dataclass(frozen=True, kw_only=True) class TestCase(BaseRegularTestCase): diff --git a/uv.lock b/uv.lock index 5a68ed80..0098df28 100644 --- a/uv.lock +++ b/uv.lock @@ -688,6 +688,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3e/95/c7c34aa53c16353c56d0b802fba48d5f5caa2cdee7958acbcb795c830416/isort-8.0.1-py3-none-any.whl", hash = "sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75", size = 89733, upload-time = "2026-02-28T10:08:19.466Z" }, ] +[[package]] +name = "jeepney" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, +] + [[package]] name = "joblib" version = "1.5.3" @@ -1708,11 +1717,12 @@ wheels = [ [[package]] name = "sampletones" -version = "0.3.0" +version = "0.3.1" source = { editable = "." } dependencies = [ { name = "anytree" }, { name = "dearpygui" }, + { name = "jeepney", marker = "sys_platform == 'linux' or (extra == 'extra-11-sampletones-gpu' and extra == 'extra-11-sampletones-gpu-cuda11')" }, { name = "librosa" }, { name = "msgpack" }, { name = "numpy" }, @@ -1762,6 +1772,7 @@ requires-dist = [ { name = "cupy-cuda11x", marker = "extra == 'gpu-cuda11'", specifier = ">=13,<14" }, { name = "cupy-cuda12x", extras = ["ctk"], marker = "extra == 'gpu'", specifier = ">=14,<15" }, { name = "dearpygui", specifier = ">=2.3,<3" }, + { name = "jeepney", marker = "sys_platform == 'linux'", specifier = ">=0.8,<1" }, { name = "librosa", specifier = ">=0.11,<0.12" }, { name = "msgpack", specifier = ">=1.0,<2" }, { name = "numpy", specifier = ">=2.0,<3" },